# 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.

Source: https://voxgig.com/howto/flush-spans-from-a-serverless-function

- Audience: platform-team
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://opentelemetry.io/docs/specs/otlp/) JSON, and the freeze is a process that
ends the moment the handler returns.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [ADOT Lambda layer](https://aws-otel.github.io/docs/getting-started/lambda/) | Lambda, and you want the collector out of your code and the export off the response path | A layer version per region and runtime, a collector extension whose running time counts against your function timeout, and one more thing to upgrade | You are not on Lambda, or the layer's bundled instrumentation is not the one you need |
| [Awaiting `forceFlush` before returning](https://open-telemetry.github.io/opentelemetry-js/classes/_opentelemetry_sdk-trace-base.BasicTracerProvider.html) | Any runtime, any exporter, and you can afford one export round trip per invocation | The export's latency added to every response, and a flush that times out still delays the caller | The collector is across a network and the response time budget is tight |
| [SimpleSpanProcessor per span](https://open-telemetry.github.io/opentelemetry-js/classes/_opentelemetry_sdk-trace-base.SimpleSpanProcessor.html) | Few spans per invocation and a collector on localhost | One export request per span rather than per batch, and no guarantee at all without a flush, as the measurement shows | An invocation produces dozens of spans, or nothing waits for the export |
| [Workers `waitUntil`](https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil) | Cloudflare Workers, where the runtime keeps the isolate alive for the promise you hand it | A 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 overruns | You 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.

```ts title="tracing.mjs"
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.

```ts title="invoke.mjs"
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.

```bash
node harness.mjs
```

```text output
@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.

```ts title="handler.mjs"
  } 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`](https://opentelemetry-python.readthedocs.io/en/latest/sdk/trace.html)
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](https://aws-otel.github.io/docs/getting-started/lambda/lambda-js/) 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](https://opentelemetry.io/docs/platforms/faas/lambda-auto-instrument/)
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.

```ts title="flush.test.mjs"
  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 },
    ])
  })
```

```bash
node --test flush.test.mjs
```

```text output
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](https://opentelemetry.io/docs/specs/otel/protocol/exporter/), 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`.

## Related how-tos

- [Tag SDK calls with session and request ids](/howto/tag-sdk-calls-with-session-and-request-ids)

- [Run one service locally and at the edge without forking it](/howto/run-one-service-locally-and-at-the-edge)

- [Set an SLO for a third party API you depend on](/howto/set-slos-for-a-third-party-api)

## 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.

[^1]: The five seconds is the specification's number rather than one SDK's. The
[batching processor](https://opentelemetry.io/docs/specs/otel/trace/sdk/#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.

[^2]: The sentence that makes a sidecar possible is one line of the
[lifecycle documentation](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtime-environment.html).
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.

[^3]: The limit is exact and so is the message. Cloudflare's
[documentation](https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil) 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.