@keegancodes/pi-otel
v1.0.0
Published
OpenTelemetry extension for the pi coding agent — ships metrics, logs, and traces over OTLP with the session ID on every signal.
Maintainers
Readme
@keegancodes/pi-otel
OpenTelemetry extension for the pi coding agent.
Ships metrics, logs, and traces over OTLP (gRPC or HTTP/JSON). The
session ID is attached to every signal — no cardinality flags, no opt-in
gate. If telemetry is enabled, every span, metric, and log record is stamped
with session.id.
Design choices
session.idis always present on every metric, log event, and span. Enable telemetry, get the session ID — there is no separate flag for it.- Simple config.
PI_OTEL_ENABLEis the master switch, or telemetry auto-enables whenOTEL_EXPORTER_OTLP_ENDPOINTis set. No health probes and no dynamic defaults — explicit is better than implicit. - No protobuf-over-HTTP. The OTel JS SDK's HTTP exporters serialize as JSON
(
Content-Type: application/json), so this calls the transport what it is:http/json. - Fail-safe. Every handler is wrapped so a telemetry fault never crashes or blocks the agent. Content fields are opt-in and off by default.
Requirements
- Node.js >= 22.19.0 (matching pi's own requirement)
- An OTLP endpoint — an OpenTelemetry Collector, or any OTLP-compatible backend
Install
pi install npm:@keegancodes/pi-otelThat is the whole install. pi fetches the package from npm into its managed
package directory (~/.pi/agent/npm/node_modules/), reads the pi.extensions
field from the package manifest, and loads src/index.ts through its own
TypeScript loader.
Pin or track a range if you prefer:
pi install npm:@keegancodes/[email protected] # exact
pi install npm:@keegancodes/pi-otel@^1.0.0 # rangeAdd -l / --local to record it in the project's settings
(.pi/settings.json) instead of your user settings. Update and remove with:
pi update npm:@keegancodes/pi-otel
pi uninstall npm:@keegancodes/pi-otelFrom source
To hack on it, clone into pi's auto-discovered extensions directory instead —
pi scans that directory one level deep and loads any subdirectory whose
package.json carries a pi.extensions field:
git clone https://github.com/keegandonley/pi-otel.git ~/.pi/agent/extensions/pi-otel
cd ~/.pi/agent/extensions/pi-otel
npm installUse /reload after edits.
A bare
npm install @keegancodes/pi-otelinto the extensions directory does not work — npm nests the package undernode_modules/@keegancodes/, deeper than pi's one-level scan. Usepi install npm:or clone.
Layout
@keegancodes/pi-otel/
├── package.json # declares pi.extensions -> ./src/index.ts
└── src/
├── index.ts # entry — wires lifecycle events to signals
├── config.ts # env-var parsing
├── providers.ts # OTel provider/exporter init, shutdown, flush
├── traces.ts # pi.* span hierarchy
├── metrics.ts # pi.* metric instruments
└── events.ts # pi.* log eventsConfiguration
All config is via environment variables. Everything is optional.
Enabling
| Variable | Default | Notes |
|----------|---------|-------|
| PI_OTEL_ENABLE | unset | Master switch, honoured both ways. "1"/"true" forces on; "0"/"false" forces off even when an endpoint is set. |
| OTEL_EXPORTER_OTLP_ENDPOINT | http://localhost:4317 | Setting this also enables telemetry. |
When PI_OTEL_ENABLE is set, it decides — in both directions. Only when it is
unset does a configured OTEL_EXPORTER_OTLP_ENDPOINT auto-enable telemetry.
When disabled, pi-otel posts a one-line warning on session_start and does
nothing else.
Transport & resource
| Variable | Default | Notes |
|----------|---------|-------|
| OTEL_EXPORTER_OTLP_PROTOCOL | grpc | grpc (port 4317) or http/json (port 4318). Any http/* value, http/protobuf included, uses the HTTP exporters. |
| OTEL_EXPORTER_OTLP_HEADERS | (none) | Comma-separated key=value pairs. Sent as headers over HTTP, and as gRPC metadata over gRPC. |
| OTEL_SERVICE_NAME | pi-coding-agent | Resource service.name. |
| OTEL_RESOURCE_ATTRIBUTES | (none) | Comma-separated key=value pairs. |
Over http/json, each signal is exported to its own standard OTLP route joined
onto the endpoint base — v1/metrics, v1/logs, v1/traces. A collector
listening on http://localhost:4318 works as-is. The per-signal env vars
(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and friends) take priority when set and
are used verbatim, with no path appended.
Over gRPC all signals share one connection, so the base endpoint is used as-is.
Content opt-in (all default off)
These gates control whether potentially sensitive payloads are attached to signals. All off by default — only metadata (lengths, sizes, counts) ships.
| Variable | Attaches to |
|----------|-------------|
| OTEL_LOG_USER_PROMPTS | full prompt text |
| OTEL_LOG_ASSISTANT_RESPONSES | full assistant response text |
| OTEL_LOG_TOOL_DETAILS | tool input/params, tool errors |
| OTEL_LOG_TOOL_CONTENT | tool I/O as span events |
| OTEL_LOG_RAW_API_BODIES | raw provider request/response bodies |
Signals
Metrics
Eight counters, all carrying session.id.
| Instrument | Unit | Labels | Source |
|------------|------|--------|--------|
| pi.session.count | – | session.id, start_type | session_start |
| pi.token.usage | tokens | session.id, model, type (input/output/cacheRead/cacheWrite) | message_end |
| pi.cost.usage | USD | session.id, model | message_end |
| pi.lines_of_code.count | lines | session.id, type (added/removed) | tool_result (edit/write) |
| pi.code_edit_tool.decision | – | session.id, decision (accept), tool_name, language | tool_result (edit/write) |
| pi.commit.count | – | session.id | tool_result (bash matching git … commit) |
| pi.pull_request.count | – | session.id | tool_result (bash matching gh … pr … create) |
| pi.active_time.total | s | session.id | turn_start → turn_end |
Lines of code: edit counts +/- lines from the patch; write counts lines
in the new content. Language is derived from the file extension.
Logs
Log events (OTel Logs), all carrying session.id. Content fields only attach
when the matching OTEL_LOG_* gate is on.
| Event name | Key attributes | Gate |
|------------|----------------|------|
| pi.user_prompt | prompt_length (+prompt) | OTEL_LOG_USER_PROMPTS |
| pi.assistant_response | response_length, model (+response) | OTEL_LOG_ASSISTANT_RESPONSES |
| pi.api_request | model, response_id, token counts, cost_usd | always |
| pi.api_refusal | model, finish_reason (WARN) | always |
| pi.tool_result | tool_name, success, input_size, output_size (+tool_parameters) | OTEL_LOG_TOOL_DETAILS |
| pi.tool_call | tool_name, input_size (+tool_parameters) | OTEL_LOG_TOOL_DETAILS |
| pi.api_request_body | model, body | OTEL_LOG_RAW_API_BODIES |
| pi.api_response_body | model, body | OTEL_LOG_RAW_API_BODIES |
| pi.api_error | status_code (ERROR) | always |
| pi.compaction | trigger, will_retry, from_extension, pre_tokens, summary_length | always |
pi.api_refusal is defensive: pi's current StopReason values are stop,
length, toolUse, error, aborted, deferred, pending — none of which
match, so this event only fires if a provider surfaces a refusal reason later.
Log attributes are limited to fields pi's events actually carry. Per-call
durations live on spans, not log records, because message_end and
tool_result have no duration field; pi.api_error reports only
status_code, because after_provider_response is just { status, headers }.
Traces
Span hierarchy driven by pi lifecycle events. session.id is stamped on every
span.
pi.interaction one per agent run
├── pi.llm_request one per provider call (gen_ai.* attrs)
├── pi.tool one per tool call
│ └── pi.tool.execution| Span | Key attributes | Notes |
|------|----------------|-------|
| pi.interaction | session.id, prompt_length (+user_prompt), duration_ms | opened on agent_start, closed on agent_end |
| pi.llm_request | session.id, gen_ai.system=pi, gen_ai.request.model, gen_ai.response.id, gen_ai.usage.input_tokens/output_tokens, finish_reasons, duration_ms | opened before provider request, closed on assistant message_end |
| pi.tool | session.id, tool_name, file_path, duration_ms (+tool.input/tool.output span events) | file_path is read from args.path/filePath/file_path |
| pi.tool.execution | session.id, tool_name, success, duration_ms, error | ERROR status set on tool failure |
Safety
- Fail-safe wrappers. Every handler is wrapped in a
try/catchthat swallows errors, so a telemetry fault never breaks the agent. - Init failure degrades to no-op. A bad endpoint or missing transport logs an error notification and continues without telemetry.
- Flush on
agent_end. Critical for headless one-shot runs that would otherwise exit before the batch processors drain. - Shutdown on
session_shutdown. Drains all three providers.
Quick start
Point at a local collector over gRPC:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
piOr HTTP/JSON, with prompt and response content enabled:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_LOG_USER_PROMPTS=1
export OTEL_LOG_ASSISTANT_RESPONSES=1
piOn session_start, the footer reports the live status:
pi-otel: live → grpc http://localhost:4317 (service=pi-coding-agent)Development
npm install # includes pi's types for typechecking
npm run typechecknpm run typecheck also runs automatically on npm publish.
License
MIT © Keegan Donley — see LICENSE.
