Skip to content

Your Logging Sucks!

Naseebullah AhmadiSenior Software Engineer, London

Your checkout logs a line at every step, which looks like good observability. Then a customer says their payment failed, and you find out what those logs can't tell you.

9 min read
#engineering

A customer says their payment failed. You open the log search, type their email, and get back a handful of lines in three formats, none of which say why. Here's how a normal checkout handler ends up there:

@itsnas TypeScript
async function checkout(req: Request) {
  logger.info('received checkout request')
 
  const cart = await getCart(req.body.cartId)
  logger.info('loaded cart')
 
  await validateStock(cart.items)
  logger.info('stock confirmed')
 
  const attempt1 = await stripe.paymentIntents.create({
    amount: cart.total,
    currency: 'usd',
  })
  logger.info('payment attempt 1: declined')
 
  const attempt2 = await stripe.paymentIntents.create({
    amount: cart.total,
    currency: 'usd',
  })
  logger.info('payment attempt 2: succeeded')
 
  const order = await createOrder(cart, attempt2.id)
  logger.info('order confirmed')
 
  return json(200, { orderId: order.id })
}
main
Nas (@itsnas)
One request, thirteen log lines, no way to group them

Run it for one user and it reads like a story. Run it for real traffic, two checkouts a few hundred milliseconds apart, and this is what the log viewer shows:

checkout-service (production)
  1. 14:32:01.204ZINFO[gateway]received checkout request
  2. 14:32:01.211ZINFO[gateway]received checkout request
  3. 14:32:01.340ZINFO[checkout]loaded cart
  4. 14:32:01.352ZINFO[checkout]loaded cart
  5. 14:32:01.398ZDEBUG[inventory]item sku_9f2 in stock: 12 units
  6. 14:32:01.410ZINFO[checkout]stock confirmed
  7. 14:32:01.487ZINFO[checkout]stock confirmed
  8. 14:32:01.780ZWARN[payments]stripe api slow response: 612ms
  9. 14:32:01.902ZINFO[payments]payment attempt 1: declined
  10. 14:32:01.918ZINFO[payments]payment attempt 1: declined
  11. 14:32:02.201ZINFO[payments]payment attempt 2: succeeded
  12. 14:32:02.266ZINFO[checkout]order confirmed
  13. 14:32:02.310ZINFO[payments]payment attempt 2: succeeded
  14. 14:32:02.375ZINFO[checkout]order confirmed
  15. 14:32:02.402ZINFO[gateway]sent response 200
  16. 14:32:02.418ZINFO[gateway]sent response 200
Two checkouts, running a few hundred milliseconds apart, interleaved exactly as they arrived

Which payment attempt 1: declined is whose? Both lines are identical, and neither carries a user, order or request ID. The ticket says "declined, then it worked, but I paid twice", and you have four lines that could belong to either customer.

Tagging every line with a request ID lets you reassemble one request, which helps, but you're still joining a dozen rows in your head, and you only get the fields someone typed on that line. The decline reason, the attempt number, the user's plan: if they weren't on the line, they existed in memory for a moment and then were gone.

Why "just use OpenTelemetry" doesn't fix it

#opentelemetry is a real answer to a different question: how do you get logs, traces, and metrics off a box and into a vendor without hand-rolling an exporter for each one. It says nothing about what a log line should contain. Wrap the same step-by-step logging in OTel spans and you get the same thirteen disconnected fragments, now shipped over a standard protocol instead of stdout. The problem was never the transport.

The fix: one wide event per request

The fix is a wide event: instead of a log line per step, build a single event object over the life of the request, adding fields to it as things happen, and emit it exactly once, at the end. Stripe calls the same idea a canonical log line: one row per request that's the authoritative record of what happened to it.

@itsnas TypeScript
async function checkout(req: Request) {
  const event = createEvent('checkout', { requestId: req.id })
 
  try {
    const cart = await getCart(req.body.cartId)
    event.set({
      userId: req.userId,
      cartId: cart.id,
      cartTotal: cart.total,
      itemCount: cart.items.length,
    })
 
    await validateStock(cart.items)
 
    let intent: Stripe.PaymentIntent | undefined
    let attempts = 0
 
    for (const key of [
      idempotencyKey(req, 1),
      idempotencyKey(req, 2),
    ]) {
      attempts++
      intent = await stripe.paymentIntents.create(
        { amount: cart.total, currency: 'usd', confirm: true },
        { idempotencyKey: key },
      )
      if (intent.status === 'succeeded') break
      event.set({
        [`declineCode_${attempts}`]:
          intent.last_payment_error?.decline_code,
      })
    }
 
    event.set({
      paymentAttempts: attempts,
      paymentStatus: intent?.status,
    })
    if (intent?.status !== 'succeeded') {
      throw new PaymentDeclinedError(intent?.id)
    }
 
    const order = await createOrder(cart, intent.id)
    event.set({ orderId: order.id, outcome: 'success' })
 
    return json(200, { orderId: order.id })
  } catch (error) {
    event.set({
      outcome: 'error',
      errorType:
        error instanceof Error ? error.constructor.name : 'unknown',
      errorMessage:
        error instanceof Error ? error.message : String(error),
    })
    throw error
  } finally {
    event.emit()
  }
}
main
Nas (@itsnas)
Same handler, one event instead of thirteen log lines

Each request builds its own event in its own closure, so there's nothing to interleave. At the end, one call writes every field that mattered as one row:

@itsnas JSON
{
  "event": "checkout",
  "requestId": "req_9f2a",
  "userId": "usr_412",
  "cartId": "cart_88b1",
  "cartTotal": 4999,
  "itemCount": 2,
  "declineCode_1": "insufficient_funds",
  "paymentAttempts": 2,
  "paymentStatus": "succeeded",
  "orderId": "ord_7712",
  "outcome": "success",
  "durationMs": 842
}
main
Nas (@itsnas)
What actually gets emitted, once, at the end

Same traffic, same viewer, one row per request. The level now tells you the outcome before you read anything: error never completed, warn completed with something off, debug was boring enough to ignore.

checkout-service (production)
  1. 14:32:01.204ZINFO[checkout]checkout requestId=req_9f2a userId=usr_412 cartId=cart_88b1 cartTotal=4999 itemCount=2 declineCode_1=insufficient_funds paymentAttempts=2 paymentStatus=succeeded orderId=ord_7712 outcome=success durationMs=842
  2. 14:32:01.223ZERROR[checkout]checkout requestId=req_a01c userId=usr_701 cartId=cart_55f0 cartTotal=1299 itemCount=1 declineCode_1=insufficient_funds declineCode_2=insufficient_funds paymentAttempts=2 paymentStatus=declined outcome=error errorType=PaymentDeclinedError durationMs=968
  3. 14:32:01.318ZWARN[checkout]checkout requestId=req_c17b userId=usr_233 cartId=cart_9a04 cartTotal=8499 itemCount=4 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7714 outcome=success processorLatencyMs=3120 durationMs=3210
  4. 14:32:01.402ZDEBUG[checkout]checkout requestId=req_d92f userId=usr_119 cartId=cart_2c71 cartTotal=2599 itemCount=1 paymentAttempts=0 paymentStatus=succeeded orderId=ord_7715 outcome=success replay=true durationMs=6
  5. 14:32:01.489ZINFO[checkout]checkout requestId=req_e114 userId=usr_884 cartId=cart_71a2 cartTotal=3499 itemCount=1 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7716 outcome=success durationMs=402
  6. 14:32:01.567ZINFO[checkout]checkout requestId=req_f228 userId=usr_305 cartId=cart_0c9e cartTotal=6299 itemCount=3 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7717 outcome=success durationMs=511
  7. 14:32:01.648ZERROR[checkout]checkout requestId=req_g33a userId=usr_662 cartId=cart_4d17 cartTotal=1999 itemCount=1 declineCode_1=expired_card paymentAttempts=1 paymentStatus=declined outcome=error errorType=PaymentDeclinedError durationMs=214
  8. 14:32:01.702ZINFO[checkout]checkout requestId=req_h447 userId=usr_190 cartId=cart_88ef cartTotal=899 itemCount=1 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7718 outcome=success durationMs=298
  9. 14:32:01.789ZDEBUG[checkout]checkout requestId=req_i551 userId=usr_812 cartId=cart_a201 cartTotal=1599 itemCount=1 paymentAttempts=0 paymentStatus=succeeded orderId=ord_7719 outcome=success replay=true durationMs=5
  10. 14:32:01.861ZINFO[checkout]checkout requestId=req_j665 userId=usr_047 cartId=cart_b312 cartTotal=4499 itemCount=2 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7720 outcome=success durationMs=356
  11. 14:32:01.944ZWARN[checkout]checkout requestId=req_k779 userId=usr_528 cartId=cart_c423 cartTotal=9999 itemCount=5 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7721 outcome=success processorLatencyMs=2890 durationMs=2977
  12. 14:32:02.018ZINFO[checkout]checkout requestId=req_l883 userId=usr_951 cartId=cart_d534 cartTotal=2799 itemCount=1 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7722 outcome=success durationMs=274
  13. 14:32:02.096ZERROR[checkout]checkout requestId=req_m997 userId=usr_204 cartId=cart_e645 cartTotal=5499 itemCount=2 declineCode_1=insufficient_funds declineCode_2=insufficient_funds paymentAttempts=2 paymentStatus=declined outcome=error errorType=PaymentDeclinedError durationMs=1102
  14. 14:32:02.171ZINFO[checkout]checkout requestId=req_n101 userId=usr_369 cartId=cart_f756 cartTotal=3299 itemCount=1 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7723 outcome=success durationMs=331
  15. 14:32:02.243ZINFO[checkout]checkout requestId=req_o215 userId=usr_582 cartId=cart_0867 cartTotal=1199 itemCount=1 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7724 outcome=success durationMs=289
  16. 14:32:02.319ZDEBUG[checkout]checkout requestId=req_p329 userId=usr_713 cartId=cart_1978 cartTotal=799 itemCount=1 paymentAttempts=0 paymentStatus=succeeded orderId=ord_7725 outcome=success replay=true durationMs=4
  17. 14:32:02.394ZINFO[checkout]checkout requestId=req_q443 userId=usr_856 cartId=cart_2a89 cartTotal=7499 itemCount=3 paymentAttempts=1 paymentStatus=succeeded orderId=ord_7726 outcome=success durationMs=467
  18. 14:32:02.471ZERROR[checkout]checkout requestId=req_r557 userId=usr_027 cartId=cart_3b90 cartTotal=2499 itemCount=1 declineCode_1=fraud_suspected paymentAttempts=1 paymentStatus=declined outcome=error errorType=PaymentDeclinedError durationMs=189
A slice of production traffic, same log viewer: one row per request, whatever the outcome

The support ticket is now one query, userId = usr_412: first attempt declined for insufficient funds, second succeeded, order created. And the same fields answer the aggregate questions too ("decline rate for insufficient_funds this week?"), so logs and dashboards stop being separate systems fed by separate code.

The event is only as good as its fields

The createEvent helper (full version in the snip) is deliberately thin: an object, a .set(), an .emit(). All the value is in which fields you set, and teams get that wrong in a predictable direction.

Field kindExampleUsual instinctWhy that's wrong
Low cardinality (few values)httpMethod, outcomekeepfine, but rarely what you search by
High cardinality (many values)userId, orderId, declineCodedrop, "too expensive"these are exactly what an incident searches by
Failure-path detaildecline codes, retry countset on success onlythe failure path is where you need them

The "drop high-cardinality fields" habit comes from string-search log platforms that charged the most to index them. Column stores like #clickhouse don't, so the habit no longer earns its keep. Two more rules: a logger.info inside a helper or a loop brings the interleaving back, so call event.set() there instead; and at real volume, tail-sample: keep every error and slow request, and sample the fast, boring successes down to a few percent.

The original handler wasn't wrong like a bug is wrong. It ran, it returned the right response, it logged at every step. It was wrong like a filing cabinet with no folders: everything is in the building somewhere, and finding it mid-incident is your problem. One event per request, built as the request runs and written once at the end, is the index.