{"slug": "structured-logging-in-node-js-how-a-business-id-became-my-correlationid", "title": "Structured Logging in Node.js: How a Business ID Became My correlationId", "summary": "A developer solved a log correlation problem in AWS Step Functions by using the execution name as the correlation ID. Instead of generating a separate UUID, the engineer used the business case ID as the Step Functions execution name, making it available in every Lambda via the $$.Execution.Name context variable. This eliminated the need to manually pass identifiers between steps and simplified debugging when customers reported issues months later.", "body_md": "I've been working in software for more than 15 years. This is the first piece I've decided to write about the job. I picked this topic because it was a small, real problem that cost me time more than once, until I finally stopped and fixed it properly.\n\nQuick disclaimer: this isn't distributed tracing or multi-service observability. It's a targeted fix for one specific problem: correlating logs within a single Step Function execution. If your scenario is different, the problem takes a different shape. What matters here is the reasoning, not the recipe.\n\nQuick context for anyone who doesn't work with serverless: AWS Step Functions is an orchestration service. You design a workflow as a state machine (JSON), and each state usually triggers an AWS Lambda (a function that runs on demand, no server to manage). In my case, a request kicks off a state machine execution that passes through several Lambdas in sequence: some do validation, others call external services, and one waits on a callback response before moving on.\n\nThe fix turned out to be simpler than it sounds: a field Step Functions already offered for free, and I just wasn't using it.\n\nA request was processed 120 days ago. The customer calls: *\"Did you receive my request? I never heard back.\"*\n\nYou open the AWS Console. The Step Functions execution is already gone. Execution history only sticks around for 90 days, and even within that window, finding one specific execution among thousands is already a hassle. That leaves CloudWatch, with logs from every Lambda in the pipeline.\n\nYou have one piece of data: the case ID. But every Lambda logged its own way: no standard, no correlation between them. To reconstruct what happened, you have to open each function's logs in the order the workflow probably ran, and piece it together by hand.\n\nIt wasn't the error itself that hurt. It was the time lost just **organizing** the logs before you could actually start investigating.\n\nThe pipeline wasn't huge, but it wasn't trivial either: a few decision steps with branches (the path changed depending on the flow type), and a callback step (a Lambda waiting on an external response before continuing).\n\nEach Lambda logged on its own, with `console.log()`\n\nscattered through the code. The bare minimum identifier (the case ID) did show up in some logs, but there was no consistency across functions. One Lambda logged a plain string, another logged an object, and none of them followed the same format.\n\nThe result: no real correlation. To reconstruct what had happened, you had to guess the workflow's likely execution order and open the log groups one by one, trying to piece the timeline together by hand.\n\nAnd the short retention window made things worse: without the execution history, all that was left were the raw CloudWatch logs, and since they had no shared structure, you couldn't use one to make up for the absence of the other. The one missing piece was exactly the link that would have made the two complete each other.\n\nOne option would be to generate a fresh correlationId (a UUID) at the start of each execution and pass it manually through every Lambda. It's the more obvious solution, and it works. But in my case it didn't make sense: I already had the case ID, which identified the execution from start to finish. Creating a second ID just to correlate logs would mean keeping two identifiers for the same thing, and every investigation would turn into a translation exercise: find the case ID first, then look up its associated correlationId, and only then search the logs.\n\nIt wasn't a complicated realization. AWS Step Functions already lets you name each execution (`name`\n\n) when it starts, and that name is available in **every state in the workflow** through the `$$.Execution.Name`\n\ncontext variable, with nothing to pass manually between steps. All I had to do was use what was already there: instead of generating a random UUID as the execution name, I used the **case ID itself**:\n\n``` js\nconst input: StartExecutionCommandInput = {\n  stateMachineArn: getStateMachineArn(),\n  name: data.id, // the case ID becomes the execution name\n  input: JSON.stringify(data),\n};\n```\n\nFrom there, any state in the state machine can access that ID with zero effort:\n\n```\n\"Parameters\": {\n  \"data.$\": \"$\",\n  \"correlationId.$\": \"$$.Execution.Name\"\n}\n```\n\nOne single identifier, from start to finish: the same case ID that shows up in the database is what shows up in the logs of every Lambda in the pipeline. No extra UUID, no mapping table, no translation needed.\n\nThe solution has a few pieces that fit together.\n\nThe core is a `createLogger(layer, category)`\n\nfunction that returns a logger that's already \"pre-configured,\" aware of where it's being called from. This eliminates the repetition of manual fields in every log call:\n\n```\nexport type LogLevel = 'info' | 'warn' | 'error';\nexport type LogLayer = 'handler' | 'service' | 'wrapper' | 'authorizer' | 'util';\n\nexport interface LogEntry {\n  message: string;\n  eventCode: string;\n  category: string;\n  layer: LogLayer;\n  correlationId?: string;\n  [key: string]: unknown;\n}\n\nconst REDACTED_KEYS = ['token', 'authorization', 'password', 'secret', 'apikey', 'x-api-key'];\nconst REDACTED_VALUE = '[REDACTED]';\n\nexport const redact = (value: unknown): unknown => {\n  if (Array.isArray(value)) return value.map(redact);\n\n  if (value !== null && typeof value === 'object') {\n    return Object.entries(value as Record<string, unknown>).reduce<Record<string, unknown>>(\n      (acc, [key, nestedValue]) => {\n        const shouldRedact = REDACTED_KEYS.some((k) => key.toLowerCase().includes(k));\n        acc[key] = shouldRedact ? REDACTED_VALUE : redact(nestedValue);\n        return acc;\n      },\n      {},\n    );\n  }\n\n  return value;\n};\n\nconst writeLog = (level: LogLevel, entry: LogEntry): void => {\n  const redacted = redact(entry) as Record<string, unknown>;\n  console[level === 'warn' ? 'warn' : level === 'error' ? 'error' : 'info'](redacted);\n};\n\nexport const createLogger = (layer: LogLayer, category: string) => ({\n  info: (entry: Omit<LogEntry, 'layer' | 'category'>) =>\n    writeLog('info', { ...entry, layer, category }),\n  warn: (entry: Omit<LogEntry, 'layer' | 'category'>) =>\n    writeLog('warn', { ...entry, layer, category }),\n  error: (entry: Omit<LogEntry, 'layer' | 'category'>) =>\n    writeLog('error', { ...entry, layer, category }),\n});\n```\n\nThree details worth calling out:\n\n`token`\n\n, `password`\n\n, `secret`\n\n, etc. gets replaced before it hits the console, without relying on manual discipline in every log call.`layer`\n\n+ `category`\n\n`handler`\n\n, `service`\n\n, `wrapper`\n\n...) without having to write that out every time.`entry`\n\naccepts any additional field (`[key: string]: unknown`\n\n), so you can log practically any object (request, response, a query result, an error payload) without adapting the logger for every data type. The same logger works for handlers, services, wrappers, or utils.Each state in the state machine passes `$$.Execution.Name`\n\nalong as the `correlationId`\n\nin the payload for the next Lambda:\n\n```\n{\n  \"Type\": \"Task\",\n  \"Resource\": \"${SomeFunctionArn}\",\n  \"Parameters\": {\n    \"data.$\": \"$\",\n    \"correlationId.$\": \"$$.Execution.Name\"\n  },\n  \"ResultPath\": \"$.someResult\"\n}\n```\n\nA generic wrapper in each Lambda in the flow handles the entry/exit/error logging automatically:\n\n```\nexport function stepFunctionWrapper<TIn, TOut>(stepName: string, fn: (arg: TIn) => Promise<TOut>) {\n  return async (data: TIn): Promise<TOut> => {\n    const log = createLogger('wrapper', stepName);\n    const correlationId = (data as { correlationId?: string })?.correlationId;\n\n    try {\n      log.info({ message: 'Starting step execution', eventCode: 'StepStarted', correlationId });\n      const result = await fn(data);\n      log.info({ message: 'Step execution successful', eventCode: 'StepCompleted', correlationId });\n      return result;\n    } catch (err) {\n      log.error({ message: 'Step execution failed', eventCode: 'StepFailed', correlationId, error: String(err) });\n      throw err;\n    }\n  };\n}\n```\n\nThe wrapper handles the generic \"step started / completed / failed\" logging. But inside the business logic, it makes sense to log specific points too: not just \"started and finished,\" but also decisions and IDs that will matter in a future investigation.\n\nThe pattern that repeats in every handler:\n\n``` js\nimport { createLogger } from '../../../util/logger';\nimport { stepFunctionWrapper } from '../stepFunctionWrapper';\n\n// Logger created once, at the top of the module, reused throughout the function\nconst log = createLogger('handler', 'createRecord');\n\nexport const handler = async ({\n  data: request,\n  correlationId,\n}: {\n  data: CreateRecordRequest;\n  correlationId?: string;\n}): Promise<number> => {\n  log.info({\n    message: 'Creating record',\n    eventCode: 'HandlerStarted',\n    correlationId,\n    externalId: request.externalId, // the case ID, always attached\n  });\n\n  // ...business logic (validation, creation, etc.)\n\n  const { id: recordId } = await models.Record.create({ /* ... */ });\n\n  log.info({\n    message: 'Record created',\n    eventCode: 'HandlerSuccess',\n    correlationId,\n    externalId: request.externalId,\n    recordId, // the internal ID too, for cross-referencing with the database later\n  });\n\n  return recordId;\n};\n\nexport const createRecord = stepFunctionWrapper('createRecordHandler', handler);\n```\n\nThree small decisions that make a difference when it's time to investigate:\n\n`log`\n\nis created once, outside the function`layer`\n\n/`category`\n\nthat module represents.`correlationId`\n\nis always present, but never generated here`eventCode`\n\nas an implicit enum`HandlerStarted`\n\n, `HandlerSuccess`\n\n, `HandlerError`\n\n...): this lets you filter in CloudWatch by event type without depending on the free-text `message`\n\n.The main payoff: even in a business investigation (not just a technical error, like \"why did this record end up with the wrong status?\"), the logs already have the right IDs attached from the moment they were created. There's no need to instrument anything when the incident happens; it's already there.\n\nThe `correlationId`\n\ndoesn't stay confined to the pipeline itself. When a handler calls an external API, the `correlationId`\n\ngets passed as a parameter to the service function, which logs the request and response around the call:\n\n``` js\nexport const searchExternalService = async (\n  request: SearchRequest,\n  correlationId?: string,\n  isRetry = false,\n): Promise<SearchResponse> => {\n  try {\n    log.info({\n      message: 'Requesting external service',\n      eventCode: 'ServiceRequest',\n      correlationId,\n      requestBody: request,\n    });\n\n    const { data } = await axios.post<SearchResponse>(/* ... */);\n\n    log.info({\n      message: 'External service responded successfully',\n      eventCode: 'ServiceResponse',\n      correlationId,\n      responseBody: data,\n    });\n\n    return data;\n  } catch (error: unknown) {\n    handleError(error, isRetry, correlationId);\n    return searchExternalService(request, correlationId, true);\n  }\n};\n```\n\nIt's not the `correlationId`\n\ntraveling as a header to the third-party service. The external system has no idea it exists. It's the **request and response for that specific call** getting tied to the same correlation ID used across the rest of the pipeline. In practice, this means that when you investigate a case, you also see exactly what was sent and received for each external integration, in the right spot on the timeline, not just what happened inside your own Lambdas.\n\nThe main case in this post is the Step Functions-orchestrated workflow, but it's worth noting: for regular HTTP endpoints (outside a state machine context), there's nothing to generate. API Gateway itself already provides a unique `requestId`\n\nper request, which can play the same role:\n\n```\nexport function apiWrapper<TIn, TOut>(\n  handlerName: string,\n  fn: (arg: TIn, event: APIGatewayProxyEvent, correlationId?: string) => Promise<TOut>,\n) {\n  return async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {\n    const correlationId = event.requestContext?.requestId;\n    const log = createLogger('wrapper', handlerName);\n\n    log.info({ message: 'Request started', eventCode: 'HandlerStarted', correlationId });\n\n    try {\n      const inputData = event.body ? JSON.parse(event.body) as TIn : ({} as TIn);\n      const result = await fn(inputData, event, correlationId);\n\n      log.info({ message: 'Request handled successfully', eventCode: 'EndpointSuccess', correlationId });\n      return { statusCode: 200, body: JSON.stringify(result) };\n    } catch (err) {\n      log.error({ message: 'Request failed', eventCode: 'EndpointError', correlationId, error: String(err) });\n      return { statusCode: 500, body: JSON.stringify({ message: 'Internal error' }) };\n    }\n  };\n}\n```\n\nThe same principle from the previous section (use an identifier that already exists, instead of creating a new one) applies here too.\n\nThe customer calls again, same scenario: \"Did you receive my request?\" Except now the investigation flow is different.\n\nThe ID the customer gives me is the correlationId. There's nothing to figure out beforehand. In CloudWatch Logs Insights, I group all the pipeline's log groups into a single query and filter by the message:\n\n```\nfields @timestamp, @message, @logStream\n| filter @message like \"correlationId: 'YOUR-ID-HERE'\"\n| sort @timestamp asc\n```\n\nThe result comes back in chronological order, with the whole journey: which Lambda ran, in what order, what each one decided, and, if something failed, exactly which step and what error.\n\nWhat used to take several minutes of mentally reconstructing the execution order and opening log group after log group is now a single query, with the right ID and the whole timeline right there on screen.\n\nThere wasn't a real trade-off here. Quite the opposite. Once the pattern was designed (logger + wrappers + `eventCode`\n\nconvention), applying it became trivial. Three things helped keep it that way:\n\n`console.log`\n\n`stepFunctionWrapper`\n\nalready handles the start/end/error logging for every step, so that never needs to be rewritten for a new handler. What still takes deliberate effort is logging inside the business logic and in calls to external services, but at that point the pattern is already set, you just follow it.In the end, the pattern doesn't survive because people remember to follow it. It survives because following it became easier than not following it.", "url": "https://wpnews.pro/news/structured-logging-in-node-js-how-a-business-id-became-my-correlationid", "canonical_source": "https://dev.to/rafao1991/structured-logging-in-nodejs-how-a-business-id-became-my-correlationid-2go3", "published_at": "2026-07-21 00:18:06+00:00", "updated_at": "2026-07-21 01:01:12.175908+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["AWS Step Functions", "AWS Lambda", "CloudWatch"], "alternates": {"html": "https://wpnews.pro/news/structured-logging-in-node-js-how-a-business-id-became-my-correlationid", "markdown": "https://wpnews.pro/news/structured-logging-in-node-js-how-a-business-id-became-my-correlationid.md", "text": "https://wpnews.pro/news/structured-logging-in-node-js-how-a-business-id-became-my-correlationid.txt", "jsonld": "https://wpnews.pro/news/structured-logging-in-node-js-how-a-business-id-became-my-correlationid.jsonld"}}