# Telemetry & Tracing
URL: /docs/building-apps/capabilities/telemetry
Markdown: /docs/building-apps/capabilities/telemetry.md
Description: Stamp spans on your app's work so it shows up in the instance's traces, without importing OpenTelemetry.

Everything a Rome instance does — handling a channel message, running an agent turn, executing an action — is traced. When your app wraps its own work in a span, that work appears in the same trace tree as the host's spans, nested under whatever triggered it (an `action:*` or `hook:*` span, for example). You get timing, attributes, and error status in the instance's observability stack with a few lines of code.

`@rome-os/app-runtime` exposes this without any OpenTelemetry dependency. All OTEL machinery stays inside the host; the SDK functions forward to a bridge the host installs at startup. Your app only imports from the SDK:

```ts
import { withRomeSpan, withSdkSpan, currentSessionId } from "@rome-os/app-runtime";
```

## The API [#the-api]

| Function                             | What it does                                                                                                                                                                      |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `withRomeSpan(name, attrs, fn)`      | Runs `fn` inside a span named `name` with `attrs` attached. Ends the span when the promise settles; a thrown error is recorded and marks the span `ERROR`. This is the main call. |
| `withSdkSpan(methodName, attrs, fn)` | Convenience wrapper: same as `withRomeSpan` with the name prefixed as `sdk:<methodName>` and `sdk.method` stamped as an attribute.                                                |
| `startRomeSpan(name, attrs?)`        | Emits a detached marker span. The SDK does not return a span handle, so you cannot end it yourself — prefer `withRomeSpan`, which brackets your work.                             |
| `currentSessionId()`                 | The session id of the agent session your code is running under, or `undefined` outside one.                                                                                       |
| `runWithSession(sessionId, fn)`      | Runs `fn` with a session context established, so spans and logs inside it correlate to that session.                                                                              |

`SpanAttributes` is just `Record<string, unknown>` — the type of the `attrs` argument. Namespace your keys (`"myapp.batch_size": 12`) so they don't collide with host attributes.

## Everything is a no-op without the host [#everything-is-a-no-op-without-the-host]

The SDK functions only work when a bridge is registered, which the host does when your app runs inside a Rome instance. When no bridge is present — a plain unit test, a script running your module directly — `withRomeSpan` falls through to calling `fn()` with no span emitted, `currentSessionId()` returns `undefined`, and `runWithSession` runs `fn` verbatim. You never need to stub telemetry in tests, and instrumented code never crashes outside Rome.

The host also never blocks on telemetry: span export is fire-and-forget, so instrumentation costs your app nothing on the request path.

## Session correlation [#session-correlation]

Rome keys its trace views on sessions. When your code runs inside an agent session — an action `execute`, a hook fired during a turn — the host has already established the session context, and `withRomeSpan` stamps `session.id` on your span automatically. You don't need to do anything.

`currentSessionId()` and `runWithSession()` exist for the gap: work that *escapes* the ambient context. If your action kicks off background work (a `setTimeout`, a detached promise) that should still show up under the session, capture the id first and re-establish it:

```ts
const sessionId = currentSessionId();
queueBackgroundJob(() => {
  if (!sessionId) return doWork();
  return runWithSession(sessionId, () => doWork());
});
```

## Example [#example]

The system app's local `summon` action (called as `system:summon`) wraps each
nested agent run in its own span, so the trace tree shows a `summon:<child>`
node between the parent `action:system:summon` span and the child agent's own
spans:

```ts
import { withRomeSpan } from "@rome-os/app-runtime";

export async function executeSummon(deps: SummonDeps, agentName: string, prompt: string) {
  // The parent action span already exists; this nests a dedicated span for
  // the child agent's run inside it, with the child's name as an attribute.
  return withRomeSpan(`summon:${agentName}`, { "rome.summon.child_agent": agentName }, async () => {
    let result = "";
    for await (const msg of deps.agentRunner.run({ agentName, prompt })) {
      if (msg.type === "result") result = msg.content;
    }
    return { result };
  });
}
```

The same pattern fits any unit of app work worth timing: an external API call, a batch import, a per-item loop body. Name spans as `<domain>:<what>` (the host uses `action:*`, `hook:*`, `agent:*`, `sdk:*`) and put variable detail in attributes, not the name, so spans of the same kind group together in queries.

> **Watch out:** through the SDK, `startRomeSpan` starts *and immediately ends* the span — it records a zero-duration point in the trace, not an open interval, because the SDK never hands you a span to end later. Use it only as an event marker. Anything with a duration belongs in `withRomeSpan` / `withSdkSpan`.

You will also see `setTelemetryBridge` and the `TelemetryBridge` interface in the SDK's exports. Those are host wiring — the Rome core calls `setTelemetryBridge` at startup to install the real implementations. Apps never call it.