How-to › Operate integrations in production

How to export outbound call spans from a serverless function#

Get the span for the API call your function made to the collector before the environment freezes: flush it, hand it to a runtime task, or let a sidecar drain it.

Audience
Platform team
Level
intermediate
Topic
Instrument and meter API calls
Languages
TypeScript and Python
Verified

Your function calls a payment API, wraps the call in a span, returns in 200 milliseconds, and the span never shows up. The exporter batches spans and sends them every five seconds. The environment froze the moment the handler returned, so the timer never fired, and the trace for the one call you needed to see is the one that is missing.

What you get

You will end up with a handler whose outbound call span reaches the collector on every cold start, and a harness that proves it by counting spans after the process has ended. This is for you if you run OpenTelemetry inside Lambda or Workers.

Short answer

A batching span processor exports on a five second timer, and a function that returns in 200 milliseconds never reaches it. Await the tracer provider’s forceFlush before returning, or hand the flush to a runtime task such as a Workers waitUntil. On Lambda, export to a collector on localhost that an extension drains after the response. Test it from a cold start and count spans at the collector.

You will need

Node 22 or later, the OpenTelemetry JavaScript SDK, and an OTLP endpoint to export to. Verified 2026-09-25 against Node 22.22.2, @opentelemetry/sdk-trace-base 2.11.0, @opentelemetry/exporter-trace-otlp-http 0.222.0, and @opentelemetry/instrumentation-undici 0.32.0. No cloud service was called. The collector is a local HTTP server that accepts OTLP/HTTP JSON, and the freeze is a process that ends the moment the handler returns.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
ADOT Lambda layerLambda, and you want the collector out of your code and the export off the response pathA layer version per region and runtime, a collector extension whose running time counts against your function timeout, and one more thing to upgradeYou are not on Lambda, or the layer’s bundled instrumentation is not the one you need
Awaiting forceFlush before returningAny runtime, any exporter, and you can afford one export round trip per invocationThe export’s latency added to every response, and a flush that times out still delays the callerThe collector is across a network and the response time budget is tight
SimpleSpanProcessor per spanFew spans per invocation and a collector on localhostOne export request per span rather than per batch, and no guarantee at all without a flush, as the measurement showsAn invocation produces dozens of spans, or nothing waits for the export
Workers waitUntilCloudflare Workers, where the runtime keeps the isolate alive for the promise you hand itA 30 second limit after the response, shared by every task you hand it, and a flush that is cancelled with a log line if it overrunsYou are on Lambda, which offers handler code no equivalent

The choice is latency against completeness, and against how many moving parts you own. forceFlush is complete and costs the caller an export round trip on every invocation. A sidecar keeps the response fast and adds a component with its own failure mode and its own share of your timeout. waitUntil is the sidecar’s trade made by the runtime on your behalf, with a 30 second ceiling.

Reproduce the loss before fixing it

The tracing setup is the ordinary one: a provider, an OTLP exporter, and a span processor chosen by an environment variable so the harness can swap it.

const exporter = new OTLPTraceExporter({ url: `${process.env.COLLECTOR_URL}/v1/traces` })

// BatchSpanProcessor exports on a timer, 5000 ms by default, or when 512 spans have queued.
// SimpleSpanProcessor starts an export the moment each span ends, and does not wait for it.
const processor = process.env.PROCESSOR === 'simple'
  ? new SimpleSpanProcessor(exporter)
  : new BatchSpanProcessor(exporter)

export const provider = new NodeTracerProvider({
  resource: resourceFromAttributes({ 'service.name': 'order-fn' }),
  spanProcessors: [processor],
})
provider.register()

// Every fetch call in the process now produces a client span.
registerInstrumentations({ instrumentations: [new UndiciInstrumentation()] })

The five seconds and the 512 are the specification’s defaults, not this SDK’s.1 The harness runs the handler once per configuration in a fresh process, and ends that process the moment the response is ready.

const tasks = []
const context = { waitUntil: (promise) => tasks.push(promise) }

let code = 0
try {
  const response = await handler({ path: '/orders', base: process.env.RATE_BASE ?? 'EUR' }, context)
  console.log(`response ${response.statusCode}`)
} catch (e) {
  // What Lambda reports as a function error. The process still ends the same way.
  console.log(`error ${e.message}`)
  code = 1
}

// A runtime that offers waitUntil keeps running until these settle, then stops.
await Promise.all(tasks)
if (process.env.LINGER) await new Promise((resolve) => setTimeout(resolve, Number(process.env.LINGER)))
process.exit(code)

Ending the process is the harshest reading of a freeze: nothing still in flight ever completes. Lambda freezes the environment when the runtime and every extension have signalled that they are done, and how much of the event loop runs before that is not something to build on.

node harness.mjs
@opentelemetry/sdk-trace-base 2.11.0, exporter-trace-otlp-http 0.222.0, instrumentation-undici 0.32.0, node 22.22.2

BatchSpanProcessor, nothing awaited                      response 200  spans at collector: 0
SimpleSpanProcessor, nothing awaited                     response 200  spans at collector: 0
BatchSpanProcessor, await forceFlush before returning    response 200  spans at collector: 2  GET (client), handle-order (internal)
BatchSpanProcessor, forceFlush handed to waitUntil       response 200  spans at collector: 2  GET (client), handle-order (internal)
BatchSpanProcessor, process kept alive 6s afterwards     response 200  spans at collector: 2  GET (client), handle-order (internal)

Two spans per invocation: the client span the undici instrumentation makes for the fetch, named GET, and the handler’s own. The first two rows lose both. The batch processor is waiting for a timer that never fires. The simple processor started an export the moment each span ended, and was still waiting for a socket when the process went away. Starting something and finishing it are different things.

The last row is the one that fools people. Keep the process alive for six seconds, as a local development server does between requests, and the timer fires and every span arrives. A test that passes on a laptop and fails in production is usually this row.

Flush before you return

The fix is one awaited call, placed where an error cannot skip it.

  } finally {
    // In a finally, so the span for a failed call is flushed too: that is the one you want.
    // 'await' adds the export round trip to the response time. 'waituntil' hands the flush to
    // the runtime and returns at once, which is what a Workers ctx.waitUntil does. A flush
    // that fails is logged and never fails the invocation: telemetry is not the job.
    if (FLUSH === 'await') await flush()
    if (FLUSH === 'waituntil') context.waitUntil(flush())
  }
}

// The provider rejects with one error per processor that failed, so the log names each one.
const flush = () => provider.forceFlush().catch((errs) => console.error('flush failed:', [errs].flat().map((e) => e?.message ?? String(e)).join('; ')))

forceFlush on the provider calls forceFlush on every processor it holds. For the batch processor that exports the queue now; for the simple processor it waits for the exports already in flight, so the call is the same either way. The catch is not decoration. A collector that is down must cost the caller time and nothing else, and a test points the exporter at a closed port to hold the handler to that. The Python SDK’s TracerProvider.force_flush does the same job with a 30 second default timeout, and the placement rule is the same: in a finally, before the return.

The finally matters more than it looks. A first draft flushed after the span function returned, which meant a 503 from the upstream skipped the flush and lost the span with the error on it. The test suite now fails a cold start whose upstream answers 503 unless both spans arrive marked as errors.

Let something else drain the buffer

Awaiting the flush puts an export round trip inside every response. When that is too slow, the alternative is to make the export local and let a component that outlives the handler send it on.

On Lambda that component is a collector running as an extension. The ADOT layer bundles one with the SDK: add the layer, set AWS_LAMBDA_EXEC_WRAPPER to /opt/otel-handler, and the Node.js instructions give an ARN of the form arn:aws:lambda:<region>:901920570463:layer:aws-otel-nodejs-<architecture>-ver-1-30-2:6. The function’s exporter then points at http://localhost:4318, which the auto-instrumentation page lists as the endpoint for http/json. Lambda does not freeze the environment until each extension has finished with the invocation, so the collector gets its turn after the response is posted.2 That turn is charged against the function timeout, because there is no separate post-invoke phase.

On Workers the component is the runtime itself. Pass the flush promise to ctx.waitUntil and the response goes out immediately, while the isolate stays alive until the promise settles, for up to 30 seconds after the invocation ends.3 The fourth row of the measurement is that path, modelled by a harness that waits for the tasks it was handed before ending the process.

Check it worked

Every test is a fresh process, so it is a cold start by construction, and every span is counted at the collector after that process has ended.

  test('a failed upstream call is flushed too, with the error on both spans', async () => {
    const r = await coldStart(collector, upstream, { PROCESSOR: 'batch', FLUSH: 'await', RATE_BASE: 'FAIL' })
    assert.equal(r.code, 1, 'the function reported an error')
    assert.equal(r.out, 'error rates answered 503')
    assert.deepEqual(r.spans, [
      { name: 'GET', kind: 'client', error: true },
      { name: 'handle-order', kind: 'internal', error: true },
    ])
  })
node --test flush.test.mjs
1..1
# tests 7
# suites 1
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0

The assertion that matters is the client span, GET with kind client, arriving with an error status on the run where the upstream failed. That is the span you will be looking for during an incident, and it is the one every configuration without a flush lost.

When it goes wrong

Spans arrive one invocation late, which means the last invocation’s spans never arrive. A batch queued when the environment froze goes out when the environment thaws for the next request. A warm function therefore looks like it is exporting with a delay, right up to the invocation nobody follows. Flush.

The test passes locally and production shows nothing. The local process outlives the timer, as the fifth row shows. Make the test end the process when the handler returns, and count at the collector rather than in the exporter’s logs.

The function times out while flushing. The exporter waits up to OTEL_EXPORTER_OTLP_TIMEOUT, ten seconds by default in the exporter specification, and an unreachable collector makes every invocation pay it. Set the exporter timeout well under the function timeout, and send to a collector on localhost when the network is not yours.

When the function times out waiting for the upstream, it loses both spans. The platform kills the handler mid-call, so the finally never runs. Give the call a timeout well under the function’s, as the handler does with AbortSignal.timeout, and a test holds it to that.

When not to do this

Do not add forceFlush to a long-lived server. The batch processor exists for processes that stay up, and flushing on every request there buys nothing and costs a round trip per request.

Do not await a flush across the internet inside a response whose latency you are measured on. Put a collector on localhost, through the layer or a sidecar, and let the local hop be the one you wait for.

Do not rely on SimpleSpanProcessor on its own. The measurement is unambiguous: it starts the export without waiting for it, so the row reads zero. Its case is fewer spans queued between flushes, not a way to avoid the flush.

Do not test from a warm environment, or from a script that stays alive after the handler returns. Both let the timer fire and both report success for the wrong reason.

Do not flush only on the success path. The span for the failed call is the one you will want, and a flush placed after the return is skipped by the throw.

Last verified

Verified 2026-09-25 against Node 22.22.2, @opentelemetry/api 1.9.1, @opentelemetry/sdk-trace-base 2.11.0, @opentelemetry/sdk-trace-node 2.11.0, @opentelemetry/exporter-trace-otlp-http 0.222.0, and @opentelemetry/instrumentation-undici 0.32.0. Every output block is what the command preceding it printed. The verification used local servers for the collector and the upstream API, and a process ending for the freeze, instead of a Lambda, a Worker, or a layer.

Footnotes

  1. The five seconds is the specification’s number rather than one SDK’s. The batching processor section lists scheduledDelayMillis at 5000, exportTimeoutMillis at 30000, maxQueueSize at 2048, and maxExportBatchSize at 512, and every SDK carries the same four. A timer that fires every five seconds and a function that lives for two hundred milliseconds were designed in different rooms. ↩︎ Back to text

  2. The sentence that makes a sidecar possible is one line of the lifecycle documentation. Lambda freezes the execution environment, it says, when the runtime and each extension have completed and there are no pending events. Each extension. A collector running as one is still awake after the handler has answered, for as long as it needs to send what it holds. The same page adds that there is no independent post-invoke phase, so that time is charged to the function’s timeout. ↩︎ Back to text

  3. The limit is exact and so is the message. Cloudflare’s documentation gives waitUntil 30 seconds after the invocation ends, shared by every promise handed to it in the same request. Anything unsettled at that point is cancelled, it says, with a warning in Workers Logs: waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled. A log line written in the past tense, for a reader who arrives too late to do anything about it. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.