# Runtime instrumentation

> What Elide instruments without code changes: server spans and metrics for served requests, fetch client spans, process metrics, build and test spans in CI with TRACEPARENT, and what is not instrumented yet.

With a destination configured, Elide records these without code changes:

| Area | Spans | Metrics | Setting |
| --- | --- | --- | --- |
| Served HTTP requests | `SERVER` span per request | `http.server.request.duration`, `http.server.active_requests`, `elide.http.server.rejected_requests` | `runtime.traces`, `runtime.metrics` |
| `fetch` | `CLIENT` span per request | `http.client.request.duration` | `runtime.traces`, `runtime.metrics` |
| Process | — | CPU time, memory, threads, file descriptors, uptime | `runtime.metrics` |
| Errors | `exception` events | `elide.errors` | follows logs, traces, metrics |
| `elide build`, `elide test`, `elide install` | Command, task, and test spans | — | `runtime.traces` |
| Elide's own logs | — | — | `runtime.logs` |

Names, units, and attributes are listed in [Emitted telemetry](https://elide.help/docs/opentelemetry/reference).

## Serving

Every request served by `elide serve`, an exported `fetch` handler, or a `node:http` server gets a server span and
the HTTP server metrics. The handler runs inside the span, so spans your code starts there are its children:

```ts
// server.ts
import { trace } from "@opentelemetry/api";

const tracer = trace.getTracer("orders");

export default {
  async fetch(request: Request): Promise<Response> {
    return tracer.startActiveSpan("load order", async (span) => {
      span.setAttribute("order.id", new URL(request.url).searchParams.get("id") ?? "");
      span.end();
      return new Response("ok\n");
    });
  },
};
```

```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 elide serve server.ts
```

A request carrying a valid `traceparent` continues the caller's trace. A 5xx response, a handler that throws, an
abandoned response, or a reset connection marks the server span `ERROR`.

Request rate comes from the count of `http.server.request.duration`: divide the increase in count by
the elapsed seconds. These counts include unsampled requests, so changing trace sampling does not
change the reported throughput. `http.server.active_requests` tracks requests still in flight.

`http.server.request.body.size` and `http.server.response.body.size` record body sizes in bytes.
Known empty bodies contribute zero; unknown response sizes are omitted. These histograms share the
duration metric's method, scheme, protocol, status, error, and known route dimensions. Raw paths,
query strings, user agents, and baggage are not automatic metric dimensions. Body-size metric names
follow OpenTelemetry's development semantic conventions.

### Routes

Server spans are named after the method: `GET`. A `node:http` server can name the matched route, which sets
`http.route` and renames the span `GET /items/:id`:

```ts
// routes.ts
import http from "node:http";
import { trace } from "@opentelemetry/api";

http
  .createServer((req, res) => {
    trace.getActiveSpan()?.setAttribute("http.route", "/items/:id");
    res.end("ok\n");
  })
  .listen(8091);
```

A router that sets `req.route` (Express and compatible routers) supplies the route without that line; `req.baseUrl`
is prefixed. Inside a `node:http` handler or an exported `fetch` handler the active span stands for the server
span, and only `http.route` set on it reaches the exported span.

## fetch

Each `fetch` gets a client span, parented to the span active where `fetch` was called, and sends `traceparent` and
`tracestate` so the next service continues the trace. A `traceparent` header you set yourself is kept.

```ts
import { trace } from "@opentelemetry/api";

await trace.getTracer("sync").startActiveSpan("sync inventory", async (span) => {
  await fetch("https://inventory.example.com/items"); // child CLIENT span
  span.end();
});
```

## Process metrics

`process.cpu.time`, `process.memory.usage`, `process.thread.count`, `process.unix.file_descriptor.count`, and
`process.uptime`, collected at each metric export on Linux and macOS.

## Builds and tests in CI

### Enable build reports

Build-report export is opt-in: configure an OTLP destination with CLI flags, `elide.pkl`, or
[environment variables](https://elide.help/docs/opentelemetry/configuration#environment-variables). With no destination configured,
Elide exports no build or test telemetry. Once a destination accepts traces, `runtime.traces` enables the command,
task, execution-unit, and individual-test spans together; it defaults to `true`. There is no separate build-report flag.

For one invocation, point Elide at a running OTLP/HTTP collector. Place these global flags before the command:

```bash
elide --otel-endpoint=http://localhost:4318 --otel-protocol=http/protobuf build
elide --otel-endpoint=http://localhost:4318 --otel-protocol=http/protobuf test
```

For a project, add this configuration to `elide.pkl` (merge the block into an existing manifest):

```pkl
amends "elide:project.pkl"

name = "my-project"

opentelemetry {
  service { name = "my-project-builds" }
  destinations {
    ["collector"] {
      endpoint = "http://localhost:4318"
      protocol = "http/protobuf"
      signals { "traces" }
    }
  }
  runtime { traces = true }
}
```

Then run `elide build` or `elide test` normally. Replace the endpoint with your collector's base URL;
Elide appends `/v1/traces` for HTTP export. For gRPC, use port `4317` and protocol `grpc`.
The destination above exports only traces. Configure authentication through
[environment-backed headers](https://elide.help/docs/opentelemetry/configuration#destinations).

To suppress telemetry for one invocation, even when the project or environment enables it:

```bash
elide --no-otel build
elide --no-otel test
```

In the manifest, `opentelemetry { enabled = false }` disables all telemetry. To keep application telemetry but
suppress automatic build/test spans, set `opentelemetry { runtime { traces = false } }`; this also disables Elide's
automatic HTTP spans. Setting only `traces { enabled = false }` controls application spans and does not disable
runtime spans. `OTEL_TRACES_EXPORTER=none` disables both kinds of trace export.

To verify export, find `elide.build` or `elide.test` in your collector under the configured service name, then group
its descendants by `elide.invocation.id`. Task, execution, and test spans appear when that work actually runs.
Sampling still applies: an unsampled upstream `TRACEPARENT` suppresses spans with the default parent-based sampler.
For a local diagnostic run, `OTEL_TRACES_SAMPLER=always_on` records every span; it does not guarantee delivery if
an exporter queue fills. See [Troubleshooting](https://elide.help/docs/opentelemetry/troubleshooting) if no spans arrive.

`ELIDE_BUILD_TELEMETRY` is a separate local timing-profile switch. It is not required for OTLP build reports and
does not enable OTLP export.

### Correlate builds and tests

`elide build`, `elide test`, and `elide install` record a root span for the command (`elide.build`, `elide.test`,
`elide.install`). Build stages and tasks become child spans with outcome and cache-hit attributes, and each test case
nests under its execution unit (a JS file or JVM launcher fork), which nests under the owning task. The root span
continues a W3C `TRACEPARENT` from the environment, so a CI job's own trace can include the build:

```bash
TRACEPARENT=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 \
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com \
elide test
```

The command span carries `elide.ci.*` attributes from GitHub Actions, GitLab CI, Buildkite, and CircleCI variables
(see [Emitted telemetry](https://elide.help/docs/opentelemetry/reference#toolchain)) and `process.exit.code`. Elide waits up to 5 seconds
for the export before the command returns.

Build-report spans carry schema version `1`, an `elide.span.type`, and an `elide.invocation.id` on every span.
The invocation ID combines the command's trace ID and span ID: two commands can share a CI trace without merging
into one build. Use this ID for a single report, and workspace/project name plus task path and test identity to
compare reports across runs. Scope comparisons to the same repository or service; project names alone are not
globally unique. CI revision and pipeline attributes remain on the command span.

Task spans include their implementation type, workspace-qualified path, prerequisite paths, cacheability, and
outcome. Driver operations remain nested under tasks. JS files have zero-based shard indexes and the discovered
file count; JVM source-set forks have separate execution IDs, including discovery fallback forks. This describes
local execution units; it does not add distributed sharding. Each execution reports result counts and becomes an
error on a failed test or unsuccessful execution, even when the runner returned normally.

Individual tests carry a stable, length-prefixed display-path identity and an occurrence number within their
execution. Identical display paths intentionally share a comparison key; occurrences distinguish emitted results,
not retries. JUnit's text feed cannot yet provide native unique IDs. Test span positions are reconstructed from
result arrival and reported duration, clamped to their execution's lifetime; `elide.test.duration_ns` preserves the
reported duration. This is sufficient for result comparison but not an exact concurrent-test timeline.
Execution spans measure their full lifecycle. Filtered tests contribute to execution counts but emit no test span.

Telemetry remains subject to tracing configuration, sampling, and exporter queue limits. It is not a lossless build
event archive. Structured JUnit identities/start events, per-task scheduler wait, artifact provenance, and a durable
report store are follow-up work; this schema does not claim complete Bazel or Gradle report parity.

In GitHub Actions:

```yaml
- name: Test
  run: elide test
  env:
    OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_ENDPOINT }}
    OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_HEADERS }}
    OTEL_SERVICE_NAME: my-service-ci
```

## Elide's own logs

Elide's warnings and errors are exported as log records in the `dev.elide.runtime` scope, at or above `runtime.logs`
(`warn` by default), with the active span's ids. Set `runtime { logs = "off" }` to stop them.

Rust instrumentation and library diagnostics also use this runtime scope. Structured log fields and span
parentage are retained. Console output follows `ELIDE_LOG` and the active console or progress logger;
export independently follows `runtime.logs`. Application log and trace settings do not enable runtime signals.

Compio's internal task and poll spans are off by default. Set `ELIDE_OTEL_RUNTIME_INTERNAL_TRACES=true`
to include them when runtime tracing is enabled and a telemetry destination is configured. This can produce
many spans; library log events still follow `runtime.logs`. Tokio's unstable internal tracing is not enabled
in shipped builds. Library events do not automatically become metrics, and exporter diagnostics are excluded
from export to prevent feedback.

## Not instrumented yet

| Area | Workaround |
| --- | --- |
| `node:http` and `node:https` client requests: no client span, no `traceparent` | Inject headers with `propagation.inject`; see [Trace context](https://elide.help/docs/opentelemetry/correlation#trace-context-over-http) |
| Python HTTP clients (`urllib`, `requests`) | Inject headers with `opentelemetry.propagate.inject` |
| Serving from Python | Serve from JavaScript or TypeScript |
| Garbage collection and event-loop delay metrics | — |
| Database, file system, and child process spans | Create spans in your code |
| Java and Kotlin programs | Not supported; see [Languages](https://elide.help/docs/opentelemetry#languages) |
