docs/handbook/engineering/playbooks/structured-logging.mdx
All structured logging on the server goes through evlog — logger.{info,warn,error,debug}({ fields }, msg) and wideEvent.set/error/timed from @activepieces/server-utils.
The field keys (not the message string) are the real schema — they are what dashboards, alerts, and the OTLP drain query against. If the same thing is logged under different keys (runId here, flowRunId there, a bare id elsewhere), every query becomes a guessing game and correlation silently breaks.
One rule above all: one concept = one field, everywhere.
Following evlog's guidance, group related fields under the entity they belong to instead of flat, prefixed keys. An entity's own id is just id inside its group.
// ✅ Grouped — clear, and flattens to flowRun.id / flow.id
logger.info({
flowRun: { id: run.id, status: run.status },
flow: { id: run.flowId },
project: { id: run.projectId },
}, 'Flow run started')
// ❌ Flat prefixes, alias, and a bare id
logger.info({ runId: run.id, flowRunId: run.id, id: run.id }, 'Flow run started')
The group name is the camelCase singular entity from the domain model. The id lives at entity.id, and the entity's attributes sit beside it in the same group.
| Group | Fields | Entity |
|---|---|---|
flowRun | id, status, environment | A flow execution |
flow | id, version | A flow definition |
flowVersion | id | A flow version |
project | id | A project |
platform | id | A platform / workspace |
user | id | A user |
job | id, type | A queue job |
piece | name, version | An integration piece |
connection | id | An app connection |
sandbox | id | An execution sandbox |
worker | id | A worker process |
webhook | id, requestId, mode, flowFound, responseStatus | A webhook request |
conversation | id | A chat conversation |
waitpoint | id | A pause / resume point |
step | name | A flow step |
trigger | name | A flow trigger |
migration | name | A database migration |