Tracing for Functions
Functions run one file of TypeScript on the Bun runtime. Bun doesn't have OpenTelemetry built in, and automatic instrumentation doesn't support Bun, so a function exports spans with the OpenTelemetry JavaScript SDK from inside its single file. The SDK reads the variables Railway provides, and a few lines wrap the requests the function handles and makes.
Before you start, enable tracing for the function in the environment it runs in. Railway adds the OTEL_* variables on the next deploy, and the SDK reads them.
How a function differs from a service
A function has no repository, build step, or start command. Railway writes the file to index.tsx, turns its imports into a package.json, installs the packages with bun install at every start, and runs the file with Bun. This changes how you add the SDK:
- The SDK setup lives at the top of the same file. There is no
--requireflag,--preloadoption, orbunfig.tomlto load it separately. That works because the SDK doesn't patch any library. It only needs to start before the function serves its first request. - Packages are pinned in the import specifier, for example
import { Hono } from "hono@4". Pin the SDK the same way. Each package you import adds to the time the function takes to start. - No OpenTelemetry package instruments
Bun.serve, Bun'sfetch,Bun.sql, orBun.redis. The SDK gives you the exporter, the resource, and context propagation. The spans come from the Hono middleware or from a wrapper you write, as shown below. - Saving the code is a deploy, so the save that adds the SDK is also the deploy that picks up the
OTEL_*variables.
Set up the SDK
Import the SDK and start it before anything else in the file:
import { NodeSDK } from "@opentelemetry/sdk-node@0.222.0";
const sdk = new NodeSDK();
sdk.start();
process.on("SIGTERM", () => sdk.shutdown().finally(() => process.exit(0)));NodeSDK reads OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_HEADERS, and OTEL_SERVICE_NAME from the variables Railway provides, and exports over OTLP/HTTP. Don't pass an endpoint or a service name in code. The SIGTERM handler exports the last batch of spans when Railway replaces the deployment.
The SDK also exports metrics and logs to the same endpoint by default, and Railway's receiver accepts traces only. Set these two variables on the function:
OTEL_METRICS_EXPORTER=none
OTEL_LOGS_EXPORTER=noneThe SDK package has no stable major version, so pin it to an exact version as shown and update it deliberately. @opentelemetry/api, hono, and @hono/otel follow semantic versioning and can be pinned to a major.
Instrument incoming requests
The edge sends a traceparent header with each request it traces. The middleware or wrapper below reads it, so the function's spans continue the edge's trace instead of starting their own.
Hono
For a function built on Hono, add the @hono/otel middleware. It records one server span per request, named after the method and route, with http.route and http.response.status_code attributes, and marks responses with a 5xx status as errors.
import { NodeSDK } from "@opentelemetry/sdk-node@0.222.0";
import { Hono } from "hono@4";
import { httpInstrumentationMiddleware } from "@hono/otel@1";
const sdk = new NodeSDK();
sdk.start();
process.on("SIGTERM", () => sdk.shutdown().finally(() => process.exit(0)));
const app = new Hono();
app.use(httpInstrumentationMiddleware());
app.get("/hello/:name", (c) => c.json({ greeting: `Hello, ${c.req.param("name")}` }));
export default { port: Number(Bun.env.PORT ?? 3000), fetch: app.fetch };Bun.serve
For a function that calls Bun.serve directly, wrap the fetch handler. The wrapper extracts the parent context from the request headers, opens a server span around the handler, and records the response status:
import { NodeSDK } from "@opentelemetry/sdk-node@0.222.0";
import { context, propagation, trace, SpanKind, SpanStatusCode } from "@opentelemetry/api@1";
const sdk = new NodeSDK();
sdk.start();
process.on("SIGTERM", () => sdk.shutdown().finally(() => process.exit(0)));
const tracer = trace.getTracer("webhook");
async function handle(req: Request): Promise<Response> {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/webhook") {
const event = await req.json();
return Response.json({ received: event.id });
}
return new Response("Not found", { status: 404 });
}
Bun.serve({
port: Number(Bun.env.PORT ?? 3000),
fetch(req) {
const url = new URL(req.url);
const parent = propagation.extract(context.active(), req.headers, {
get: (headers, key) => headers.get(key) ?? undefined,
keys: (headers) => [...headers.keys()],
});
return tracer.startActiveSpan(
`${req.method} ${url.pathname}`,
{
kind: SpanKind.SERVER,
attributes: { "http.request.method": req.method, "url.path": url.pathname },
},
parent,
async (span) => {
try {
const res = await handle(req);
span.setAttribute("http.response.status_code", res.status);
if (res.status >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
return res;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
},
);
},
});Trace outgoing requests
Bun's fetch doesn't add the traceparent header on its own, so a call to another Railway service starts a separate trace there. Wrap fetch to record a client span and inject the trace context. The other service's spans then appear under the function's span in the same trace.
import { context, propagation, trace, SpanKind, SpanStatusCode } from "@opentelemetry/api@1";
const tracer = trace.getTracer("storefront");
async function tracedFetch(input: string | URL, init: RequestInit = {}): Promise<Response> {
const url = new URL(input);
const method = init.method ?? "GET";
return tracer.startActiveSpan(
`${method} ${url.host}`,
{
kind: SpanKind.CLIENT,
attributes: { "http.request.method": method, "url.full": url.href, "server.address": url.hostname },
},
async (span) => {
const headers = new Headers(init.headers);
propagation.inject(context.active(), headers, { set: (h, key, value) => h.set(key, value) });
try {
const res = await fetch(url, { ...init, headers });
span.setAttribute("http.response.status_code", res.status);
if (res.status >= 400) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
return res;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
},
);
}
app.get("/stock/:sku", async (c) => {
const res = await tracedFetch(`http://inventory.railway.internal:8080/stock/${c.req.param("sku")}`);
return c.json(await res.json());
});Call tracedFetch from inside a request handler or a span, as above. The client span becomes a child of whichever span is active when it runs.
Add a custom span
Wrap the work you care about in your own span with the OpenTelemetry API. The span becomes a child of the request span that's active when the function runs, so it appears nested in the trace, and the attributes you set are searchable on the Traces page, for example with @cart.items:>10.
import { trace, SpanStatusCode } from "@opentelemetry/api@1";
const tracer = trace.getTracer("checkout");
async function calculateTotal(cart: Cart) {
return tracer.startActiveSpan("calculate-total", async (span) => {
try {
span.setAttribute("cart.items", cart.items.length);
return await priceItems(cart);
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
}Cron and script functions
A function with a cron schedule, or one that runs to completion without serving requests, receives no request from the edge. Its spans start a new trace on each run. Every run is recorded.
The process exits as soon as the script finishes, before the SDK's batch processor exports on its own. Shut the SDK down at the end so the spans leave the process:
import { NodeSDK } from "@opentelemetry/sdk-node@0.222.0";
import { trace } from "@opentelemetry/api@1";
const sdk = new NodeSDK();
sdk.start();
const tracer = trace.getTracer("cleanup");
await tracer.startActiveSpan("delete-expired-sessions", async (span) => {
try {
const deleted = await deleteExpiredSessions();
span.setAttribute("sessions.deleted", deleted);
} finally {
span.end();
}
});
await sdk.shutdown();Find these traces on the Traces page with @service:<function name>. They have no edge span, so a filter on @component:edge doesn't match them.
Verify
- Deploy the function and send it a few requests.
- Open the Traces page. In the Tracing setup panel, the App indicator for the function turns green when its first span arrives.
- Open a trace. The request span from the middleware or your wrapper, and any child spans, appear under the edge and proxy spans.
If spans from the function don't arrive, read the deploy logs first. A failed bun install means an import specifier is wrong, and export errors for metrics or logs mean the two none variables are missing. Then see Troubleshooting.