pi-telemetry-otel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/extensions/index.ts +792 -0
- package/extensions/runtime-registry.ts +30 -0
- package/extensions/span-context-registry.ts +31 -0
- package/helpers/index.ts +132 -0
- package/index.ts +12 -0
- package/lib/agent-chain.ts +75 -0
- package/lib/trace-env.ts +160 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# pi OTEL Telemetry Extension
|
|
2
|
+
|
|
3
|
+
Emits OpenTelemetry spans for pi agent lifecycle + tool usage to an OTLP/HTTP collector (Jaeger).
|
|
4
|
+
|
|
5
|
+
## Install / enable
|
|
6
|
+
|
|
7
|
+
Install as a **pi package** (recommended):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Global install (writes ~/.pi/agent/settings.json)
|
|
11
|
+
pi install npm:pi-telemetry-otel
|
|
12
|
+
|
|
13
|
+
# Project-local install (writes .pi/settings.json)
|
|
14
|
+
pi install -l npm:pi-telemetry-otel
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Try without installing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi -e npm:pi-telemetry-otel "List files"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Pi loads the extension from the package’s `pi.extensions` manifest automatically once installed (unless disabled via `pi config`).
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
All settings accept `PI_` overrides (e.g. `PI_OTEL_SERVICE_NAME`) and fall back to standard OTEL env vars.
|
|
28
|
+
|
|
29
|
+
| Env var | Default | Purpose |
|
|
30
|
+
| ----------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318/v1/traces` | OTLP/HTTP endpoint |
|
|
32
|
+
| `OTEL_EXPORTER_OTLP_HEADERS` | _(none)_ | Comma-separated `k=v` headers |
|
|
33
|
+
| `OTEL_SERVICE_NAME` | `pi-agent` | Service name in Jaeger (defaults to agent name from `PI_AGENT_CHAIN` if present) |
|
|
34
|
+
| `OTEL_RESOURCE_ATTRIBUTES` | _(none)_ | Comma-separated `k=v` attributes |
|
|
35
|
+
| `PI_AGENT_TRACE_ID` | _(generated)_ | Parent trace ID to reuse; set once and kept for subprocess trace linking |
|
|
36
|
+
| `PI_AGENT_SPAN_ID` | _(generated)_ | Parent span ID for subprocess linking; updated to current active span (session/agent/turn/tool) |
|
|
37
|
+
|
|
38
|
+
## Extension interoperability (child spans)
|
|
39
|
+
|
|
40
|
+
Other pi extensions can attach spans to the *current* pi trace.
|
|
41
|
+
|
|
42
|
+
### Recommended: use the helper APIs
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { withPiSpan } from "pi-telemetry-otel/helpers";
|
|
46
|
+
|
|
47
|
+
pi.on("tool_call", async (_event, ctx) => {
|
|
48
|
+
await withPiSpan(ctx, "myext.do_work", async (span) => {
|
|
49
|
+
span?.setAttribute("myext.foo", "bar");
|
|
50
|
+
// ... your work
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The helper functions:
|
|
56
|
+
- reuse the tracer/export pipeline registered by this extension
|
|
57
|
+
- parent spans under the **active** pi span (session/agent/turn/tool)
|
|
58
|
+
- prefer a per-session context registry (avoids env-var races in concurrent in-process sub-sessions)
|
|
59
|
+
|
|
60
|
+
### Advanced: global registries (no hard dependency)
|
|
61
|
+
|
|
62
|
+
If you don’t want to depend on the package, you can read the registries directly:
|
|
63
|
+
|
|
64
|
+
- Runtime registry symbol: `Symbol.for("pi.telemetry-otel.runtimeRegistry.v1")`
|
|
65
|
+
- Active span context registry symbol: `Symbol.for("pi.telemetry-otel.activeSpanContextRegistry.v1")`
|
|
66
|
+
- Map key: `ctx.sessionManager.getSessionId()`
|
|
67
|
+
|
|
68
|
+
(See `flow-machine` for a reference integration that emits `pi.flow.*` spans.)
|
|
69
|
+
|
|
70
|
+
## Open the current trace
|
|
71
|
+
|
|
72
|
+
Use `/open-jaeger-trace` to show the current trace URL and optionally open it in your browser.
|
|
73
|
+
If Tailscale is available, the command also surfaces a Tailscale IP URL for remote access.
|
|
74
|
+
|
|
75
|
+
## Jaeger smoke check
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Run pi (interactive preferred so spans flush)
|
|
79
|
+
PI_OTEL_SERVICE_NAME=pi-agent-dev pi
|
|
80
|
+
|
|
81
|
+
# Query traces
|
|
82
|
+
curl -sSf "http://localhost:16686/api/traces?service=pi-agent-dev&limit=5" | jq -r '.data[].traceID'
|
|
83
|
+
curl -sSf "http://localhost:16686/api/traces/<trace-id>" | jq -r '.data[0].spans[].operationName'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Expected operations: `pi.session`, `pi.agent`, `pi.turn`, `pi.tool: <tool-name>` (bash adds command preview).
|
|
87
|
+
|
|
88
|
+
## Tests
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cd telemetry-otel
|
|
92
|
+
bun install
|
|
93
|
+
bun test
|
|
94
|
+
```
|
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { type Attributes, type Span, SpanStatusCode, type Tracer, context, trace } from "@opentelemetry/api";
|
|
3
|
+
import { registerTelemetryRuntime, unregisterTelemetryRuntime } from "./runtime-registry";
|
|
4
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
5
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
6
|
+
import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
7
|
+
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
|
8
|
+
import { clearActiveSpanContext, setActiveSpanContext } from "./span-context-registry";
|
|
9
|
+
import { getCurrentAgentName } from "../lib/agent-chain";
|
|
10
|
+
import {
|
|
11
|
+
TraceEnvStack,
|
|
12
|
+
isValidTraceId,
|
|
13
|
+
readParentSpanContext,
|
|
14
|
+
readParentSpanContextFromEntries,
|
|
15
|
+
readServiceNameFromEntries,
|
|
16
|
+
} from "../lib/trace-env";
|
|
17
|
+
|
|
18
|
+
function invariant(condition: unknown, message: string): asserts condition {
|
|
19
|
+
if (!condition) {
|
|
20
|
+
throw new Error(message);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_ENDPOINT = "http://localhost:4318/v1/traces";
|
|
25
|
+
const DEFAULT_SERVICE_NAME = "pi-agent";
|
|
26
|
+
const PAYLOAD_MAX_BYTES = 8 * 1024;
|
|
27
|
+
const INPUT_PREVIEW_MAX_CHARS = 50;
|
|
28
|
+
const TOOL_COMMAND_PREVIEW_MAX_CHARS = 120;
|
|
29
|
+
const INPUT_LAST_SENTENCE_MAX_CHARS = 70;
|
|
30
|
+
const REDACTION_PATTERN = /api_key|token|secret|password/i;
|
|
31
|
+
const DEFAULT_JAEGER_TRACE_URL = "http://localhost:16686/trace";
|
|
32
|
+
|
|
33
|
+
interface TelemetryConfig {
|
|
34
|
+
endpoint: string;
|
|
35
|
+
headers: Record<string, string>;
|
|
36
|
+
serviceName: string;
|
|
37
|
+
resourceAttributes: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TelemetryRuntime {
|
|
41
|
+
tracer: Tracer;
|
|
42
|
+
shutdown: () => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TelemetryOptions {
|
|
46
|
+
runtime?: TelemetryRuntime;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SpanRegistry {
|
|
51
|
+
session?: Span;
|
|
52
|
+
agent?: Span;
|
|
53
|
+
turns: Map<number, Span>;
|
|
54
|
+
tools: Map<string, Span>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface InputSummary {
|
|
58
|
+
preview: string;
|
|
59
|
+
previewBytes: number;
|
|
60
|
+
previewTruncated: boolean;
|
|
61
|
+
firstSentence?: string;
|
|
62
|
+
firstSentenceTruncated?: boolean;
|
|
63
|
+
lastSentence?: string;
|
|
64
|
+
lastSentenceTruncated?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getEnvValue(key: string): string | undefined {
|
|
68
|
+
return process.env[`PI_${key}`] ?? process.env[key];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseKeyValuePairs(raw: string | undefined): Record<string, string> {
|
|
72
|
+
if (!raw) return {};
|
|
73
|
+
return raw
|
|
74
|
+
.split(",")
|
|
75
|
+
.map((pair) => pair.trim())
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.reduce<Record<string, string>>((acc, pair) => {
|
|
78
|
+
const [key, ...rest] = pair.split("=");
|
|
79
|
+
if (!key || rest.length === 0) return acc;
|
|
80
|
+
acc[key.trim()] = rest.join("=").trim();
|
|
81
|
+
return acc;
|
|
82
|
+
}, {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveServiceName(): string {
|
|
86
|
+
const explicitServiceName = getEnvValue("OTEL_SERVICE_NAME");
|
|
87
|
+
if (explicitServiceName) return explicitServiceName;
|
|
88
|
+
|
|
89
|
+
const agentName = getCurrentAgentName();
|
|
90
|
+
if (agentName) return agentName;
|
|
91
|
+
|
|
92
|
+
return DEFAULT_SERVICE_NAME;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadConfig(): TelemetryConfig {
|
|
96
|
+
const endpoint = getEnvValue("OTEL_EXPORTER_OTLP_ENDPOINT") ?? DEFAULT_ENDPOINT;
|
|
97
|
+
const headers = parseKeyValuePairs(getEnvValue("OTEL_EXPORTER_OTLP_HEADERS"));
|
|
98
|
+
const serviceName = resolveServiceName();
|
|
99
|
+
const resourceAttributes = parseKeyValuePairs(getEnvValue("OTEL_RESOURCE_ATTRIBUTES"));
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
endpoint,
|
|
103
|
+
headers,
|
|
104
|
+
serviceName,
|
|
105
|
+
resourceAttributes,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildJaegerTraceUrl(traceId: string): string {
|
|
110
|
+
return `${DEFAULT_JAEGER_TRACE_URL}/${traceId}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getOpenUrlCommand(url: string): { command: string; args: string[] } {
|
|
114
|
+
switch (process.platform) {
|
|
115
|
+
case "darwin":
|
|
116
|
+
return { command: "open", args: [url] };
|
|
117
|
+
case "win32":
|
|
118
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
119
|
+
default:
|
|
120
|
+
return { command: "xdg-open", args: [url] };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function getTailscaleIp(pi: ExtensionAPI): Promise<string | undefined> {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout, code } = await pi.exec("tailscale", ["status", "--json"], {});
|
|
127
|
+
if (code !== 0) return undefined;
|
|
128
|
+
|
|
129
|
+
const data = JSON.parse(stdout) as { Self?: { TailscaleIPs?: unknown[] } };
|
|
130
|
+
const ips = data.Self?.TailscaleIPs ?? [];
|
|
131
|
+
const first = ips.find((ip) => typeof ip === "string");
|
|
132
|
+
return typeof first === "string" ? first : undefined;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function buildTraceUrls(pi: ExtensionAPI, traceId: string): Promise<{ primary: string; tailscale?: string }> {
|
|
139
|
+
const primary = buildJaegerTraceUrl(traceId);
|
|
140
|
+
const tailscaleIp = await getTailscaleIp(pi);
|
|
141
|
+
|
|
142
|
+
if (!tailscaleIp) {
|
|
143
|
+
return { primary };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
primary,
|
|
148
|
+
tailscale: `http://${tailscaleIp}:16686/trace/${traceId}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function redactPayload(value: unknown): unknown {
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
return value.map((item) => redactPayload(item));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (value && typeof value === "object") {
|
|
158
|
+
const output: Record<string, unknown> = {};
|
|
159
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
160
|
+
if (REDACTION_PATTERN.test(key)) {
|
|
161
|
+
output[key] = "[redacted]";
|
|
162
|
+
} else {
|
|
163
|
+
output[key] = redactPayload(entry);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return output;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function serializePayload(value: unknown): { text: string; bytes: number; truncated: boolean } {
|
|
173
|
+
const safe = redactPayload(value);
|
|
174
|
+
const text = JSON.stringify(safe) ?? "null";
|
|
175
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
176
|
+
if (bytes <= PAYLOAD_MAX_BYTES) {
|
|
177
|
+
return { text, bytes, truncated: false };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const truncatedText = text.slice(0, PAYLOAD_MAX_BYTES);
|
|
181
|
+
return { text: truncatedText, bytes, truncated: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildPayloadAttributes(prefix: string, value: unknown): Attributes {
|
|
185
|
+
const payload = serializePayload(value);
|
|
186
|
+
return {
|
|
187
|
+
[`${prefix}`]: payload.text,
|
|
188
|
+
[`${prefix}.bytes`]: payload.bytes,
|
|
189
|
+
[`${prefix}.truncated`]: payload.truncated,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeText(value: string): string {
|
|
194
|
+
return value.replace(/\s+/g, " ").trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function trimTextByWord(value: string, maxChars: number): { text: string; truncated: boolean } {
|
|
198
|
+
if (value.length <= maxChars) {
|
|
199
|
+
return { text: value, truncated: false };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const words = value.split(" ");
|
|
203
|
+
let result = "";
|
|
204
|
+
for (const word of words) {
|
|
205
|
+
const next = result ? `${result} ${word}` : word;
|
|
206
|
+
if (next.length > maxChars) break;
|
|
207
|
+
result = next;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!result) {
|
|
211
|
+
return { text: value.slice(0, maxChars), truncated: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { text: result, truncated: true };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildInputSummary(text: string): InputSummary {
|
|
218
|
+
const normalized = normalizeText(text);
|
|
219
|
+
const preview = trimTextByWord(normalized, INPUT_PREVIEW_MAX_CHARS);
|
|
220
|
+
const previewBytes = Buffer.byteLength(preview.text, "utf8");
|
|
221
|
+
|
|
222
|
+
if (!normalized) {
|
|
223
|
+
return {
|
|
224
|
+
preview: preview.text,
|
|
225
|
+
previewBytes,
|
|
226
|
+
previewTruncated: preview.truncated,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sentences = normalized.split(/(?<=[.!?])\s+/).filter(Boolean);
|
|
231
|
+
const firstSentence = trimTextByWord(sentences[0] ?? normalized, INPUT_PREVIEW_MAX_CHARS);
|
|
232
|
+
const lastSentence = trimTextByWord(sentences[sentences.length - 1] ?? normalized, INPUT_LAST_SENTENCE_MAX_CHARS);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
preview: preview.text,
|
|
236
|
+
previewBytes,
|
|
237
|
+
previewTruncated: preview.truncated,
|
|
238
|
+
firstSentence: firstSentence.text,
|
|
239
|
+
firstSentenceTruncated: firstSentence.truncated,
|
|
240
|
+
lastSentence: lastSentence.text,
|
|
241
|
+
lastSentenceTruncated: lastSentence.truncated,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function buildInputSummaryAttributes(prefix: string, summary: InputSummary): Attributes {
|
|
246
|
+
return {
|
|
247
|
+
[`${prefix}.preview`]: summary.preview,
|
|
248
|
+
[`${prefix}.preview.bytes`]: summary.previewBytes,
|
|
249
|
+
[`${prefix}.preview.truncated`]: summary.previewTruncated,
|
|
250
|
+
[`${prefix}.first_sentence`]: summary.firstSentence ?? "",
|
|
251
|
+
[`${prefix}.first_sentence.truncated`]: summary.firstSentenceTruncated ?? false,
|
|
252
|
+
[`${prefix}.last_sentence`]: summary.lastSentence ?? "",
|
|
253
|
+
[`${prefix}.last_sentence.truncated`]: summary.lastSentenceTruncated ?? false,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildToolSpanName(toolName: string, input: unknown): string {
|
|
258
|
+
if (toolName !== "bash") {
|
|
259
|
+
return `pi.tool: ${toolName}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const command = (input as { command?: unknown } | undefined)?.command;
|
|
263
|
+
if (typeof command !== "string") {
|
|
264
|
+
return `pi.tool: ${toolName}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const normalized = normalizeText(command);
|
|
268
|
+
if (!normalized) {
|
|
269
|
+
return `pi.tool: ${toolName}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const trimmed = trimTextByWord(normalized, TOOL_COMMAND_PREVIEW_MAX_CHARS);
|
|
273
|
+
const suffix = trimmed.truncated ? `${trimmed.text}…` : trimmed.text;
|
|
274
|
+
return `pi.tool: ${toolName}(${suffix})`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function updateSpanName(span: Span | undefined, name: string | undefined): void {
|
|
278
|
+
if (!span || !name) return;
|
|
279
|
+
span.updateName(name);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function addSpanEvent(span: Span | undefined, type: string, attrs: Attributes): void {
|
|
283
|
+
if (!span) return;
|
|
284
|
+
span.addEvent(type, {
|
|
285
|
+
"pi.event.type": type,
|
|
286
|
+
...attrs,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getSessionId(ctx: ExtensionContext): string | undefined {
|
|
291
|
+
return "getSessionId" in ctx.sessionManager ? ctx.sessionManager.getSessionId() : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function findLastStopReason(messages: Array<{ role?: string; stopReason?: string }>): string | undefined {
|
|
295
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
296
|
+
const message = messages[index];
|
|
297
|
+
if (message?.role === "assistant") {
|
|
298
|
+
return message.stopReason;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function ensureAttribute(span: Span, key: string, value: string | number | boolean | undefined): void {
|
|
305
|
+
if (value === undefined) return;
|
|
306
|
+
span.setAttribute(key, value);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function setSpanAttributes(span: Span | undefined, attrs: Attributes): void {
|
|
310
|
+
if (!span) return;
|
|
311
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
312
|
+
span.setAttribute(key, value as string | number | boolean);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function createTracer(config: TelemetryConfig): TelemetryRuntime {
|
|
317
|
+
const resource = resourceFromAttributes({
|
|
318
|
+
...config.resourceAttributes,
|
|
319
|
+
[SemanticResourceAttributes.SERVICE_NAME]: config.serviceName,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const exporter = new OTLPTraceExporter({ url: config.endpoint, headers: config.headers });
|
|
323
|
+
const processor = new BatchSpanProcessor(exporter);
|
|
324
|
+
const provider = new BasicTracerProvider({
|
|
325
|
+
resource,
|
|
326
|
+
spanProcessors: [processor],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
tracer: provider.getTracer("pi-telemetry-otel", "0.1.0"),
|
|
331
|
+
shutdown: async () => {
|
|
332
|
+
await provider.forceFlush();
|
|
333
|
+
await provider.shutdown();
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export default function telemetryOtelExtension(pi: ExtensionAPI, options: TelemetryOptions = {}): void {
|
|
339
|
+
const config = loadConfig();
|
|
340
|
+
const now = options.now ?? Date.now;
|
|
341
|
+
|
|
342
|
+
// Tracer creation is deferred until ensureSessionSpan() so we can read a
|
|
343
|
+
// service-name override from session entries (injected by parent sessions
|
|
344
|
+
// for in-process sub-agents like the ask tool).
|
|
345
|
+
let runtime: TelemetryRuntime | undefined = options.runtime;
|
|
346
|
+
let tracer: Tracer | undefined = options.runtime?.tracer;
|
|
347
|
+
let shutdown: (() => Promise<void>) | undefined = options.runtime?.shutdown;
|
|
348
|
+
|
|
349
|
+
function ensureRuntime(serviceNameOverride?: string): TelemetryRuntime {
|
|
350
|
+
if (runtime) return runtime;
|
|
351
|
+
const effectiveConfig = serviceNameOverride
|
|
352
|
+
? { ...config, serviceName: serviceNameOverride }
|
|
353
|
+
: config;
|
|
354
|
+
runtime = createTracer(effectiveConfig);
|
|
355
|
+
tracer = runtime.tracer;
|
|
356
|
+
shutdown = runtime.shutdown;
|
|
357
|
+
return runtime;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const spans: SpanRegistry = {
|
|
361
|
+
turns: new Map(),
|
|
362
|
+
tools: new Map(),
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const traceEnvStack = new TraceEnvStack<Span>();
|
|
366
|
+
const traceScopeKeys = {
|
|
367
|
+
session: "session",
|
|
368
|
+
agent: "agent",
|
|
369
|
+
turn: (turnIndex: number) => `turn:${turnIndex}`,
|
|
370
|
+
tool: (toolCallId: string) => `tool:${toolCallId}`,
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
let sessionId: string | undefined;
|
|
374
|
+
let activeTurnIndex: number | undefined;
|
|
375
|
+
let lastInputSummary: InputSummary | undefined;
|
|
376
|
+
let shutdownTriggered = false;
|
|
377
|
+
|
|
378
|
+
function updateTraceStatus(ctx: ExtensionContext, traceId: string | undefined): void {
|
|
379
|
+
if (!ctx.hasUI) return;
|
|
380
|
+
if (!traceId) {
|
|
381
|
+
ctx.ui.setStatus("telemetry-otel", undefined);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const theme = ctx.ui.theme;
|
|
385
|
+
const shortTraceId = traceId.slice(0, 8);
|
|
386
|
+
ctx.ui.setStatus("telemetry-otel", theme.fg("dim", `trace ${shortTraceId}`));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function syncActiveSpanContext(): void {
|
|
390
|
+
if (!sessionId) return;
|
|
391
|
+
const top = traceEnvStack.peek();
|
|
392
|
+
if (!top) {
|
|
393
|
+
clearActiveSpanContext(sessionId);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
setActiveSpanContext(sessionId, {
|
|
398
|
+
...top.spanContext(),
|
|
399
|
+
isRemote: false,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function ensureSessionSpan(ctx: ExtensionContext): Span {
|
|
404
|
+
if (spans.session) return spans.session;
|
|
405
|
+
|
|
406
|
+
sessionId = getSessionId(ctx);
|
|
407
|
+
const entries = ctx.sessionManager.getEntries();
|
|
408
|
+
|
|
409
|
+
// Prefer trace context from session entries (injected by parent session for
|
|
410
|
+
// in-process sub-sessions) over env vars (which race across concurrent subs).
|
|
411
|
+
// Fall back to env vars for cross-process propagation (RPC sub-agents).
|
|
412
|
+
const parentSpanContext =
|
|
413
|
+
readParentSpanContextFromEntries(entries) ?? readParentSpanContext();
|
|
414
|
+
const parentContext = parentSpanContext
|
|
415
|
+
? trace.setSpanContext(context.active(), parentSpanContext)
|
|
416
|
+
: context.active();
|
|
417
|
+
|
|
418
|
+
// Initialize runtime with service name override from entries (if present).
|
|
419
|
+
// This allows in-process sub-sessions to appear with a distinct service
|
|
420
|
+
// name in the trace (e.g. "pi-ask-agent" instead of "pi-agent").
|
|
421
|
+
const serviceNameOverride = readServiceNameFromEntries(entries);
|
|
422
|
+
const ensured = ensureRuntime(serviceNameOverride);
|
|
423
|
+
if (sessionId) {
|
|
424
|
+
registerTelemetryRuntime(sessionId, ensured);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
invariant(tracer, "Tracer not initialized");
|
|
428
|
+
const span = tracer.startSpan(
|
|
429
|
+
"pi.session",
|
|
430
|
+
{
|
|
431
|
+
startTime: now(),
|
|
432
|
+
},
|
|
433
|
+
parentContext,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
ensureAttribute(span, "pi.session.id", sessionId);
|
|
437
|
+
ensureAttribute(span, "pi.session.file", ctx.sessionManager.getSessionFile());
|
|
438
|
+
|
|
439
|
+
traceEnvStack.push(traceScopeKeys.session, span);
|
|
440
|
+
syncActiveSpanContext();
|
|
441
|
+
updateTraceStatus(ctx, span.spanContext().traceId);
|
|
442
|
+
spans.session = span;
|
|
443
|
+
return span;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function endSpan(span: Span | undefined, endTime?: number): void {
|
|
447
|
+
if (!span) return;
|
|
448
|
+
span.end(endTime);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function closeOrphanToolSpans(reason: string): void {
|
|
452
|
+
for (const [toolCallId, span] of spans.tools.entries()) {
|
|
453
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: `Closed without tool_result (${reason})` });
|
|
454
|
+
span.setAttribute("pi.tool.missing_result", true);
|
|
455
|
+
span.end();
|
|
456
|
+
traceEnvStack.pop(traceScopeKeys.tool(toolCallId));
|
|
457
|
+
}
|
|
458
|
+
spans.tools.clear();
|
|
459
|
+
syncActiveSpanContext();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function closeOrphanTurnSpans(reason: string): void {
|
|
463
|
+
for (const [turnIndex, span] of spans.turns.entries()) {
|
|
464
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: `Closed without turn_end (${reason})` });
|
|
465
|
+
span.setAttribute("pi.turn.missing_end", true);
|
|
466
|
+
span.end();
|
|
467
|
+
traceEnvStack.pop(traceScopeKeys.turn(turnIndex));
|
|
468
|
+
}
|
|
469
|
+
spans.turns.clear();
|
|
470
|
+
activeTurnIndex = undefined;
|
|
471
|
+
syncActiveSpanContext();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function closeOrphanAgentSpan(reason: string): void {
|
|
475
|
+
if (!spans.agent) return;
|
|
476
|
+
spans.agent.setStatus({ code: SpanStatusCode.ERROR, message: `Closed without agent_end (${reason})` });
|
|
477
|
+
spans.agent.setAttribute("pi.agent.missing_end", true);
|
|
478
|
+
spans.agent.end();
|
|
479
|
+
spans.agent = undefined;
|
|
480
|
+
traceEnvStack.pop(traceScopeKeys.agent);
|
|
481
|
+
syncActiveSpanContext();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function sendTraceMessage(ctx: ExtensionCommandContext, message: string, type: "info" | "error" = "info"): void {
|
|
485
|
+
if (ctx.hasUI) {
|
|
486
|
+
ctx.ui.notify(message, type);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
pi.sendMessage({
|
|
491
|
+
customType: "telemetry-otel-trace-url",
|
|
492
|
+
content: message,
|
|
493
|
+
display: true,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function openUrl(ctx: ExtensionCommandContext, url: string): Promise<void> {
|
|
498
|
+
const { command, args } = getOpenUrlCommand(url);
|
|
499
|
+
const { code, stderr } = await pi.exec(command, args, {});
|
|
500
|
+
if (code !== 0) {
|
|
501
|
+
const error = stderr.trim() || `Failed to open URL (exit ${code}).`;
|
|
502
|
+
sendTraceMessage(ctx, error, "error");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
pi.registerCommand("open-jaeger-trace", {
|
|
507
|
+
description: "Open the current Jaeger trace in your browser",
|
|
508
|
+
handler: async (_args, ctx) => {
|
|
509
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
510
|
+
const traceId = sessionSpan.spanContext().traceId;
|
|
511
|
+
if (!traceId || !isValidTraceId(traceId)) {
|
|
512
|
+
sendTraceMessage(ctx, "No valid trace ID found for this session.", "error");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const urls = await buildTraceUrls(pi, traceId);
|
|
517
|
+
const messageLines = [`Trace URL: ${urls.primary}`];
|
|
518
|
+
if (urls.tailscale) {
|
|
519
|
+
messageLines.push(`Tailscale URL: ${urls.tailscale}`);
|
|
520
|
+
}
|
|
521
|
+
const message = messageLines.join("\n");
|
|
522
|
+
|
|
523
|
+
if (!ctx.hasUI) {
|
|
524
|
+
sendTraceMessage(ctx, message, "info");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const confirm = await ctx.ui.confirm("Open Jaeger trace?", `${message}\n\nOpen in your browser?`);
|
|
529
|
+
if (!confirm) {
|
|
530
|
+
sendTraceMessage(ctx, message, "info");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
await openUrl(ctx, urls.primary);
|
|
535
|
+
sendTraceMessage(ctx, message, "info");
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
pi.on("session_start", (_event, ctx) => {
|
|
540
|
+
sessionId = getSessionId(ctx);
|
|
541
|
+
lastInputSummary = undefined;
|
|
542
|
+
ensureSessionSpan(ctx);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
pi.on("session_switch", (_event, ctx) => {
|
|
546
|
+
closeOrphanToolSpans("session_switch");
|
|
547
|
+
closeOrphanTurnSpans("session_switch");
|
|
548
|
+
closeOrphanAgentSpan("session_switch");
|
|
549
|
+
|
|
550
|
+
endSpan(spans.session, now());
|
|
551
|
+
spans.session = undefined;
|
|
552
|
+
traceEnvStack.clear();
|
|
553
|
+
if (sessionId) {
|
|
554
|
+
clearActiveSpanContext(sessionId);
|
|
555
|
+
}
|
|
556
|
+
updateTraceStatus(ctx, undefined);
|
|
557
|
+
sessionId = getSessionId(ctx);
|
|
558
|
+
lastInputSummary = undefined;
|
|
559
|
+
ensureSessionSpan(ctx);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
pi.on("input", (event, ctx) => {
|
|
563
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
564
|
+
const payloadAttributes = buildPayloadAttributes("pi.input.text", event.text);
|
|
565
|
+
const summary = buildInputSummary(event.text);
|
|
566
|
+
|
|
567
|
+
lastInputSummary = summary;
|
|
568
|
+
|
|
569
|
+
const summaryAttributes = buildInputSummaryAttributes("pi.input", summary);
|
|
570
|
+
|
|
571
|
+
addSpanEvent(sessionSpan, "input", {
|
|
572
|
+
"pi.event.source": event.source,
|
|
573
|
+
"pi.session.id": sessionId ?? "",
|
|
574
|
+
"pi.input.images": event.images?.length ?? 0,
|
|
575
|
+
...payloadAttributes,
|
|
576
|
+
...summaryAttributes,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
setSpanAttributes(sessionSpan, buildInputSummaryAttributes("pi.input.latest", summary));
|
|
580
|
+
updateSpanName(sessionSpan, summary.preview ? `pi.session ${summary.preview}` : undefined);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
584
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
585
|
+
const parent = trace.setSpan(context.active(), sessionSpan);
|
|
586
|
+
invariant(tracer, "Tracer not initialized");
|
|
587
|
+
const span = tracer.startSpan("pi.agent", { startTime: now() }, parent);
|
|
588
|
+
|
|
589
|
+
ensureAttribute(span, "pi.session.id", sessionId);
|
|
590
|
+
|
|
591
|
+
if (lastInputSummary) {
|
|
592
|
+
setSpanAttributes(span, buildInputSummaryAttributes("pi.input.latest", lastInputSummary));
|
|
593
|
+
updateSpanName(span, lastInputSummary.preview ? `pi.agent ${lastInputSummary.preview}` : undefined);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
traceEnvStack.push(traceScopeKeys.agent, span);
|
|
597
|
+
syncActiveSpanContext();
|
|
598
|
+
updateTraceStatus(ctx, span.spanContext().traceId);
|
|
599
|
+
spans.agent = span;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
pi.on("turn_start", (event, ctx) => {
|
|
603
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
604
|
+
const parent = trace.setSpan(context.active(), spans.agent ?? sessionSpan);
|
|
605
|
+
|
|
606
|
+
activeTurnIndex = event.turnIndex;
|
|
607
|
+
|
|
608
|
+
invariant(tracer, "Tracer not initialized");
|
|
609
|
+
const span = tracer.startSpan(
|
|
610
|
+
"pi.turn",
|
|
611
|
+
{
|
|
612
|
+
startTime: event.timestamp ?? now(),
|
|
613
|
+
},
|
|
614
|
+
parent,
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
ensureAttribute(span, "pi.session.id", sessionId);
|
|
618
|
+
ensureAttribute(span, "pi.turn.index", event.turnIndex);
|
|
619
|
+
|
|
620
|
+
if (lastInputSummary) {
|
|
621
|
+
setSpanAttributes(span, buildInputSummaryAttributes("pi.input.latest", lastInputSummary));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
spans.turns.set(event.turnIndex, span);
|
|
625
|
+
traceEnvStack.push(traceScopeKeys.turn(event.turnIndex), span);
|
|
626
|
+
syncActiveSpanContext();
|
|
627
|
+
addSpanEvent(span, "turn_start", {
|
|
628
|
+
"pi.turn.index": event.turnIndex,
|
|
629
|
+
"pi.session.id": sessionId ?? "",
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
pi.on("tool_call", (event, ctx) => {
|
|
634
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
635
|
+
const parentSpan = activeTurnIndex !== undefined ? spans.turns.get(activeTurnIndex) : spans.agent;
|
|
636
|
+
const parent = trace.setSpan(context.active(), parentSpan ?? sessionSpan);
|
|
637
|
+
|
|
638
|
+
invariant(tracer, "Tracer not initialized");
|
|
639
|
+
const span = tracer.startSpan(buildToolSpanName(event.toolName, event.input), { startTime: now() }, parent);
|
|
640
|
+
|
|
641
|
+
ensureAttribute(span, "pi.session.id", sessionId);
|
|
642
|
+
ensureAttribute(span, "pi.turn.index", activeTurnIndex);
|
|
643
|
+
ensureAttribute(span, "pi.tool.name", event.toolName);
|
|
644
|
+
ensureAttribute(span, "pi.tool.call_id", event.toolCallId);
|
|
645
|
+
|
|
646
|
+
spans.tools.set(event.toolCallId, span);
|
|
647
|
+
traceEnvStack.push(traceScopeKeys.tool(event.toolCallId), span);
|
|
648
|
+
syncActiveSpanContext();
|
|
649
|
+
|
|
650
|
+
addSpanEvent(span, "tool_call", {
|
|
651
|
+
"pi.tool.name": event.toolName,
|
|
652
|
+
"pi.tool.call_id": event.toolCallId,
|
|
653
|
+
"pi.session.id": sessionId ?? "",
|
|
654
|
+
"pi.turn.index": activeTurnIndex ?? -1,
|
|
655
|
+
...buildPayloadAttributes("pi.tool.input", event.input),
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
pi.on("tool_result", (event, ctx) => {
|
|
660
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
661
|
+
const span = spans.tools.get(event.toolCallId);
|
|
662
|
+
|
|
663
|
+
const outputPayload = {
|
|
664
|
+
content: event.content,
|
|
665
|
+
details: event.details,
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
addSpanEvent(span ?? sessionSpan, "tool_result", {
|
|
669
|
+
"pi.tool.name": event.toolName,
|
|
670
|
+
"pi.tool.call_id": event.toolCallId,
|
|
671
|
+
"pi.tool.is_error": event.isError,
|
|
672
|
+
"pi.session.id": sessionId ?? "",
|
|
673
|
+
"pi.turn.index": activeTurnIndex ?? -1,
|
|
674
|
+
...buildPayloadAttributes("pi.tool.output", outputPayload),
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (span) {
|
|
678
|
+
if (event.isError) {
|
|
679
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: "tool_result error" });
|
|
680
|
+
}
|
|
681
|
+
span.end();
|
|
682
|
+
spans.tools.delete(event.toolCallId);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
traceEnvStack.pop(traceScopeKeys.tool(event.toolCallId));
|
|
686
|
+
syncActiveSpanContext();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
pi.on("turn_end", (event, ctx) => {
|
|
690
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
691
|
+
const span = spans.turns.get(event.turnIndex);
|
|
692
|
+
|
|
693
|
+
const turnStopReason = "stopReason" in event.message ? event.message.stopReason : undefined;
|
|
694
|
+
|
|
695
|
+
addSpanEvent(span ?? sessionSpan, "turn_end", {
|
|
696
|
+
"pi.turn.index": event.turnIndex,
|
|
697
|
+
"pi.session.id": sessionId ?? "",
|
|
698
|
+
"pi.turn.tool_results": event.toolResults.length,
|
|
699
|
+
"pi.message.stop_reason": turnStopReason ?? "",
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
endSpan(span, now());
|
|
703
|
+
spans.turns.delete(event.turnIndex);
|
|
704
|
+
traceEnvStack.pop(traceScopeKeys.turn(event.turnIndex));
|
|
705
|
+
syncActiveSpanContext();
|
|
706
|
+
if (activeTurnIndex === event.turnIndex) {
|
|
707
|
+
activeTurnIndex = undefined;
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
712
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
713
|
+
const stopReason = findLastStopReason(event.messages);
|
|
714
|
+
|
|
715
|
+
addSpanEvent(spans.agent ?? sessionSpan, "agent_end", {
|
|
716
|
+
"pi.session.id": sessionId ?? "",
|
|
717
|
+
"pi.message.stop_reason": stopReason ?? "",
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
closeOrphanToolSpans("agent_end");
|
|
721
|
+
closeOrphanTurnSpans("agent_end");
|
|
722
|
+
|
|
723
|
+
endSpan(spans.agent, now());
|
|
724
|
+
spans.agent = undefined;
|
|
725
|
+
traceEnvStack.pop(traceScopeKeys.agent);
|
|
726
|
+
syncActiveSpanContext();
|
|
727
|
+
|
|
728
|
+
if (!ctx.hasUI && !shutdownTriggered) {
|
|
729
|
+
shutdownTriggered = true;
|
|
730
|
+
endSpan(spans.session, now());
|
|
731
|
+
spans.session = undefined;
|
|
732
|
+
traceEnvStack.clear();
|
|
733
|
+
if (sessionId) {
|
|
734
|
+
clearActiveSpanContext(sessionId);
|
|
735
|
+
unregisterTelemetryRuntime(sessionId);
|
|
736
|
+
}
|
|
737
|
+
await shutdown?.();
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
pi.on("model_select", (event, ctx) => {
|
|
742
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
743
|
+
addSpanEvent(sessionSpan, "model_select", {
|
|
744
|
+
"pi.session.id": sessionId ?? "",
|
|
745
|
+
"pi.model.provider": event.model.provider,
|
|
746
|
+
"pi.model.id": event.model.id,
|
|
747
|
+
"pi.model.source": event.source,
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
pi.on("session_compact", (event, ctx) => {
|
|
752
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
753
|
+
addSpanEvent(sessionSpan, "session_compact", {
|
|
754
|
+
"pi.session.id": sessionId ?? "",
|
|
755
|
+
"pi.compaction.first_kept_entry": event.compactionEntry?.firstKeptEntryId ?? "",
|
|
756
|
+
"pi.compaction.tokens_before": event.compactionEntry?.tokensBefore ?? 0,
|
|
757
|
+
...buildPayloadAttributes("pi.compaction.summary", event.compactionEntry?.summary),
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
pi.on("session_tree", (event, ctx) => {
|
|
762
|
+
const sessionSpan = ensureSessionSpan(ctx);
|
|
763
|
+
addSpanEvent(sessionSpan, "session_tree", {
|
|
764
|
+
"pi.session.id": sessionId ?? "",
|
|
765
|
+
"pi.tree.new_leaf": event.newLeafId ?? "",
|
|
766
|
+
"pi.tree.old_leaf": event.oldLeafId ?? "",
|
|
767
|
+
"pi.tree.from_extension": event.fromExtension ?? false,
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
772
|
+
if (shutdownTriggered) return;
|
|
773
|
+
shutdownTriggered = true;
|
|
774
|
+
|
|
775
|
+
ensureSessionSpan(ctx);
|
|
776
|
+
closeOrphanToolSpans("session_shutdown");
|
|
777
|
+
closeOrphanTurnSpans("session_shutdown");
|
|
778
|
+
closeOrphanAgentSpan("session_shutdown");
|
|
779
|
+
|
|
780
|
+
endSpan(spans.session, now());
|
|
781
|
+
spans.session = undefined;
|
|
782
|
+
traceEnvStack.clear();
|
|
783
|
+
updateTraceStatus(ctx, undefined);
|
|
784
|
+
|
|
785
|
+
if (sessionId) {
|
|
786
|
+
clearActiveSpanContext(sessionId);
|
|
787
|
+
unregisterTelemetryRuntime(sessionId);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
await shutdown?.();
|
|
791
|
+
});
|
|
792
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Tracer } from "@opentelemetry/api";
|
|
2
|
+
|
|
3
|
+
export type TelemetryOtelRuntimeHandle = {
|
|
4
|
+
tracer: Tracer;
|
|
5
|
+
shutdown: () => Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const RUNTIME_REGISTRY_SYMBOL = Symbol.for("pi.telemetry-otel.runtimeRegistry.v1");
|
|
9
|
+
|
|
10
|
+
type Registry = Map<string, TelemetryOtelRuntimeHandle>;
|
|
11
|
+
|
|
12
|
+
function getRegistry(): Registry {
|
|
13
|
+
const globalAny = globalThis as unknown as { [RUNTIME_REGISTRY_SYMBOL]?: Registry };
|
|
14
|
+
if (!globalAny[RUNTIME_REGISTRY_SYMBOL]) {
|
|
15
|
+
globalAny[RUNTIME_REGISTRY_SYMBOL] = new Map();
|
|
16
|
+
}
|
|
17
|
+
return globalAny[RUNTIME_REGISTRY_SYMBOL]!;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerTelemetryRuntime(sessionId: string, runtime: TelemetryOtelRuntimeHandle): void {
|
|
21
|
+
getRegistry().set(sessionId, runtime);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function unregisterTelemetryRuntime(sessionId: string): void {
|
|
25
|
+
getRegistry().delete(sessionId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getTelemetryRuntime(sessionId: string): TelemetryOtelRuntimeHandle | undefined {
|
|
29
|
+
return getRegistry().get(sessionId);
|
|
30
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SpanContextLike } from "../lib/trace-env";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Holds the currently active span context for a given pi session.
|
|
5
|
+
*
|
|
6
|
+
* This is intentionally stored on globalThis under a Symbol.for key so that
|
|
7
|
+
* multiple copies / versions of this package can share the same registry.
|
|
8
|
+
*/
|
|
9
|
+
export const ACTIVE_SPAN_CONTEXT_REGISTRY_SYMBOL = Symbol.for("pi.telemetry-otel.activeSpanContextRegistry.v1");
|
|
10
|
+
|
|
11
|
+
type Registry = Map<string, SpanContextLike>;
|
|
12
|
+
|
|
13
|
+
function getRegistry(): Registry {
|
|
14
|
+
const globalAny = globalThis as unknown as { [ACTIVE_SPAN_CONTEXT_REGISTRY_SYMBOL]?: Registry };
|
|
15
|
+
if (!globalAny[ACTIVE_SPAN_CONTEXT_REGISTRY_SYMBOL]) {
|
|
16
|
+
globalAny[ACTIVE_SPAN_CONTEXT_REGISTRY_SYMBOL] = new Map();
|
|
17
|
+
}
|
|
18
|
+
return globalAny[ACTIVE_SPAN_CONTEXT_REGISTRY_SYMBOL]!;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setActiveSpanContext(sessionId: string, spanContext: SpanContextLike): void {
|
|
22
|
+
getRegistry().set(sessionId, spanContext);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function clearActiveSpanContext(sessionId: string): void {
|
|
26
|
+
getRegistry().delete(sessionId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getActiveSpanContext(sessionId: string): SpanContextLike | undefined {
|
|
30
|
+
return getRegistry().get(sessionId);
|
|
31
|
+
}
|
package/helpers/index.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { type Attributes, type Span, SpanStatusCode, context, trace } from "@opentelemetry/api";
|
|
3
|
+
import { getActiveSpanContext } from "../extensions/span-context-registry";
|
|
4
|
+
import { getTelemetryRuntime, type TelemetryOtelRuntimeHandle } from "../extensions/runtime-registry";
|
|
5
|
+
import {
|
|
6
|
+
TRACE_CONTEXT_ENTRY_TYPE,
|
|
7
|
+
isValidSpanId,
|
|
8
|
+
isValidTraceId,
|
|
9
|
+
readParentSpanContext,
|
|
10
|
+
readParentSpanContextFromEntries,
|
|
11
|
+
type SpanContextLike,
|
|
12
|
+
} from "../lib/trace-env";
|
|
13
|
+
|
|
14
|
+
export interface StartPiSpanOptions {
|
|
15
|
+
/** Initial span attributes. */
|
|
16
|
+
attributes?: Attributes;
|
|
17
|
+
/** Explicit parent span context. When omitted, uses the current pi active span context. */
|
|
18
|
+
parentSpanContext?: SpanContextLike;
|
|
19
|
+
/** Start time (unix ms) or hr-time depending on OTEL impl. */
|
|
20
|
+
startTime?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getSessionId(ctx: ExtensionContext): string | undefined {
|
|
24
|
+
return "getSessionId" in ctx.sessionManager ? ctx.sessionManager.getSessionId() : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the telemetry-otel runtime handle for the current pi session (if the telemetry extension is loaded).
|
|
29
|
+
*/
|
|
30
|
+
export function getPiTelemetryRuntime(ctx: ExtensionContext): TelemetryOtelRuntimeHandle | undefined {
|
|
31
|
+
const sessionId = getSessionId(ctx);
|
|
32
|
+
if (!sessionId) return undefined;
|
|
33
|
+
return getTelemetryRuntime(sessionId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Returns the OTEL tracer registered by telemetry-otel for this session (if available). */
|
|
37
|
+
export function getPiTracer(ctx: ExtensionContext) {
|
|
38
|
+
return getPiTelemetryRuntime(ctx)?.tracer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the best available span context to parent child spans under.
|
|
43
|
+
*
|
|
44
|
+
* Preference order:
|
|
45
|
+
* 1) Per-session active span context registry (race-free for in-process concurrency)
|
|
46
|
+
* 2) Session entry trace context (in-process sub-sessions)
|
|
47
|
+
* 3) Env vars PI_AGENT_TRACE_ID / PI_AGENT_SPAN_ID (cross-process propagation)
|
|
48
|
+
*/
|
|
49
|
+
export function getPiActiveSpanContext(ctx: ExtensionContext): SpanContextLike | undefined {
|
|
50
|
+
const sessionId = getSessionId(ctx);
|
|
51
|
+
const fromRegistry = sessionId ? getActiveSpanContext(sessionId) : undefined;
|
|
52
|
+
if (fromRegistry) return fromRegistry;
|
|
53
|
+
|
|
54
|
+
const entries = ctx.sessionManager.getEntries?.() ?? [];
|
|
55
|
+
return readParentSpanContextFromEntries(entries) ?? readParentSpanContext();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start a new span as a child of the currently active pi span.
|
|
60
|
+
*
|
|
61
|
+
* Returns undefined if telemetry-otel is not loaded (no tracer runtime registered).
|
|
62
|
+
*/
|
|
63
|
+
export function startPiSpan(ctx: ExtensionContext, name: string, options: StartPiSpanOptions = {}): Span | undefined {
|
|
64
|
+
const tracer = getPiTracer(ctx);
|
|
65
|
+
if (!tracer) return undefined;
|
|
66
|
+
|
|
67
|
+
const parentSpanContext = options.parentSpanContext ?? getPiActiveSpanContext(ctx);
|
|
68
|
+
const parentContext = parentSpanContext ? trace.setSpanContext(context.active(), parentSpanContext) : context.active();
|
|
69
|
+
|
|
70
|
+
return tracer.startSpan(
|
|
71
|
+
name,
|
|
72
|
+
{
|
|
73
|
+
attributes: options.attributes,
|
|
74
|
+
startTime: options.startTime,
|
|
75
|
+
},
|
|
76
|
+
parentContext,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Convenience helper: creates a child span, runs `fn`, records errors, and ends the span.
|
|
82
|
+
*/
|
|
83
|
+
export async function withPiSpan<T>(
|
|
84
|
+
ctx: ExtensionContext,
|
|
85
|
+
name: string,
|
|
86
|
+
fn: (span: Span | undefined) => Promise<T> | T,
|
|
87
|
+
options: StartPiSpanOptions = {},
|
|
88
|
+
): Promise<T> {
|
|
89
|
+
const span = startPiSpan(ctx, name, options);
|
|
90
|
+
try {
|
|
91
|
+
return await fn(span);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (span) {
|
|
94
|
+
span.recordException(error as any);
|
|
95
|
+
span.setStatus({
|
|
96
|
+
code: SpanStatusCode.ERROR,
|
|
97
|
+
message: error instanceof Error ? error.message : String(error),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
} finally {
|
|
102
|
+
span?.end();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Capture the current trace/span IDs for correlation or sub-session propagation.
|
|
108
|
+
*/
|
|
109
|
+
export function capturePiTraceContext(ctx: ExtensionContext): { traceId: string; spanId: string } | undefined {
|
|
110
|
+
const sc = getPiActiveSpanContext(ctx);
|
|
111
|
+
if (!sc) return undefined;
|
|
112
|
+
if (!isValidTraceId(sc.traceId) || !isValidSpanId(sc.spanId)) return undefined;
|
|
113
|
+
return { traceId: sc.traceId, spanId: sc.spanId };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Inject current trace context into another SessionManager-like object (in-process sub-sessions).
|
|
118
|
+
*/
|
|
119
|
+
export function injectPiTraceContextEntry(
|
|
120
|
+
ctx: ExtensionContext,
|
|
121
|
+
sessionManager: { appendCustomEntry: (customType: string, data: unknown) => void },
|
|
122
|
+
options: { serviceName?: string } = {},
|
|
123
|
+
): void {
|
|
124
|
+
const captured = capturePiTraceContext(ctx);
|
|
125
|
+
if (!captured) return;
|
|
126
|
+
|
|
127
|
+
sessionManager.appendCustomEntry(TRACE_CONTEXT_ENTRY_TYPE, {
|
|
128
|
+
traceId: captured.traceId,
|
|
129
|
+
spanId: captured.spanId,
|
|
130
|
+
serviceName: options.serviceName,
|
|
131
|
+
});
|
|
132
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default } from "./extensions/index";
|
|
2
|
+
export * from "./extensions/index";
|
|
3
|
+
|
|
4
|
+
// Public helper APIs for other extensions/packages.
|
|
5
|
+
export * from "./helpers/index";
|
|
6
|
+
|
|
7
|
+
// Trace context utilities (env + session entry helpers).
|
|
8
|
+
export * from "./lib/trace-env";
|
|
9
|
+
|
|
10
|
+
// Global registries (advanced interop).
|
|
11
|
+
export * from "./extensions/runtime-registry";
|
|
12
|
+
export * from "./extensions/span-context-registry";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const AGENT_CHAIN_ENV = "PI_AGENT_CHAIN";
|
|
2
|
+
const AGENT_CHAIN_MAX_DEPTH_ENV = "PI_AGENT_CHAIN_MAX_DEPTH";
|
|
3
|
+
const DEFAULT_AGENT_CHAIN_MAX_DEPTH = 1;
|
|
4
|
+
|
|
5
|
+
export interface BuildAgentSpawnEnvOptions {
|
|
6
|
+
baseEnv?: NodeJS.ProcessEnv;
|
|
7
|
+
extraEnv?: NodeJS.ProcessEnv;
|
|
8
|
+
maxDepth?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BuildAgentSpawnEnvResult {
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseAgentChain(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
17
|
+
const raw = env[AGENT_CHAIN_ENV]?.trim();
|
|
18
|
+
if (!raw) return [];
|
|
19
|
+
return raw
|
|
20
|
+
.split(">")
|
|
21
|
+
.map((entry) => entry.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getCurrentAgentName(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
26
|
+
const chain = parseAgentChain(env);
|
|
27
|
+
return chain.length > 0 ? chain[chain.length - 1] : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatAgentChain(chain: string[]): string {
|
|
31
|
+
return chain.join(">");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readChainMaxDepth(
|
|
35
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
36
|
+
defaultValue: number = DEFAULT_AGENT_CHAIN_MAX_DEPTH,
|
|
37
|
+
): number {
|
|
38
|
+
const raw = env[AGENT_CHAIN_MAX_DEPTH_ENV]?.trim();
|
|
39
|
+
if (!raw) return defaultValue;
|
|
40
|
+
const parsed = Number.parseInt(raw, 10);
|
|
41
|
+
if (!Number.isFinite(parsed) || parsed < 0) return defaultValue;
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildAgentSpawnEnv(agentName: string, options: BuildAgentSpawnEnvOptions = {}): BuildAgentSpawnEnvResult {
|
|
46
|
+
const baseEnv = options.baseEnv ?? process.env;
|
|
47
|
+
const chain = parseAgentChain(baseEnv);
|
|
48
|
+
if (chain.includes(agentName)) {
|
|
49
|
+
return {
|
|
50
|
+
error: `Critical: agent chain already includes "${agentName}" (${formatAgentChain(chain)}).`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const maxDepth = options.maxDepth ?? readChainMaxDepth(baseEnv, DEFAULT_AGENT_CHAIN_MAX_DEPTH);
|
|
55
|
+
if (chain.length >= maxDepth) {
|
|
56
|
+
return {
|
|
57
|
+
error: `Critical: agent chain depth ${chain.length} exceeds max ${maxDepth}.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const nextChain = formatAgentChain([...chain, agentName]);
|
|
62
|
+
return {
|
|
63
|
+
env: {
|
|
64
|
+
...baseEnv,
|
|
65
|
+
[AGENT_CHAIN_ENV]: nextChain,
|
|
66
|
+
...options.extraEnv,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const AgentChainEnv = {
|
|
72
|
+
AGENT_CHAIN_ENV,
|
|
73
|
+
AGENT_CHAIN_MAX_DEPTH_ENV,
|
|
74
|
+
DEFAULT_AGENT_CHAIN_MAX_DEPTH,
|
|
75
|
+
};
|
package/lib/trace-env.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export const TRACE_ID_ENV = "PI_AGENT_TRACE_ID";
|
|
2
|
+
export const SPAN_ID_ENV = "PI_AGENT_SPAN_ID";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CustomEntry type used to pass parent trace context into sub-sessions via SessionManager.
|
|
6
|
+
* Preferred over env vars for in-process sub-sessions to avoid race conditions.
|
|
7
|
+
*/
|
|
8
|
+
export const TRACE_CONTEXT_ENTRY_TYPE = "telemetry-otel-trace-context";
|
|
9
|
+
|
|
10
|
+
/** Shape of the data stored in a trace context custom entry. */
|
|
11
|
+
export interface TraceContextEntryData {
|
|
12
|
+
traceId: string;
|
|
13
|
+
spanId: string;
|
|
14
|
+
/** Optional service name override for the sub-session's OTEL resource. */
|
|
15
|
+
serviceName?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TRACE_ID_LENGTH = 32;
|
|
19
|
+
const SPAN_ID_LENGTH = 16;
|
|
20
|
+
const TRACE_FLAGS_SAMPLED = 0x01;
|
|
21
|
+
|
|
22
|
+
export interface SpanContextLike {
|
|
23
|
+
traceId: string;
|
|
24
|
+
spanId: string;
|
|
25
|
+
traceFlags: number;
|
|
26
|
+
isRemote?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SpanLike {
|
|
30
|
+
spanContext(): { traceId: string; spanId: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isValidHexId(value: string, length: number): boolean {
|
|
34
|
+
if (value.length !== length) return false;
|
|
35
|
+
if (!/^[0-9a-f]+$/i.test(value)) return false;
|
|
36
|
+
return !/^0+$/.test(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isValidTraceId(value: string): boolean {
|
|
40
|
+
return isValidHexId(value, TRACE_ID_LENGTH);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isValidSpanId(value: string): boolean {
|
|
44
|
+
return isValidHexId(value, SPAN_ID_LENGTH);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readParentSpanContext(env: NodeJS.ProcessEnv = process.env): SpanContextLike | undefined {
|
|
48
|
+
const traceId = env[TRACE_ID_ENV];
|
|
49
|
+
const spanId = env[SPAN_ID_ENV];
|
|
50
|
+
|
|
51
|
+
if (!traceId || !spanId) return undefined;
|
|
52
|
+
if (!isValidTraceId(traceId) || !isValidSpanId(spanId)) return undefined;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
traceId,
|
|
56
|
+
spanId,
|
|
57
|
+
traceFlags: TRACE_FLAGS_SAMPLED,
|
|
58
|
+
isRemote: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read parent span context from session entries (CustomEntry with TRACE_CONTEXT_ENTRY_TYPE).
|
|
64
|
+
* Returns the last matching entry's data, or undefined if none found.
|
|
65
|
+
*
|
|
66
|
+
* Use this for in-process sub-sessions where env vars may conflict.
|
|
67
|
+
* Falls back to readParentSpanContext() for cross-process propagation.
|
|
68
|
+
*/
|
|
69
|
+
export function readParentSpanContextFromEntries(
|
|
70
|
+
entries: ReadonlyArray<{ type: string; customType?: string; data?: unknown }>,
|
|
71
|
+
): SpanContextLike | undefined {
|
|
72
|
+
// Scan backwards — last entry wins (most recent context)
|
|
73
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
74
|
+
const entry = entries[i];
|
|
75
|
+
if (entry?.type === "custom" && entry.customType === TRACE_CONTEXT_ENTRY_TYPE) {
|
|
76
|
+
const data = entry.data as TraceContextEntryData | undefined;
|
|
77
|
+
if (!data?.traceId || !data?.spanId) continue;
|
|
78
|
+
if (!isValidTraceId(data.traceId) || !isValidSpanId(data.spanId)) continue;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
traceId: data.traceId,
|
|
82
|
+
spanId: data.spanId,
|
|
83
|
+
traceFlags: TRACE_FLAGS_SAMPLED,
|
|
84
|
+
isRemote: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read service name override from session entries (if present in trace context entry).
|
|
94
|
+
*/
|
|
95
|
+
export function readServiceNameFromEntries(
|
|
96
|
+
entries: ReadonlyArray<{ type: string; customType?: string; data?: unknown }>,
|
|
97
|
+
): string | undefined {
|
|
98
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
99
|
+
const entry = entries[i];
|
|
100
|
+
if (entry?.type === "custom" && entry.customType === TRACE_CONTEXT_ENTRY_TYPE) {
|
|
101
|
+
const data = entry.data as TraceContextEntryData | undefined;
|
|
102
|
+
return data?.serviceName;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function syncTraceEnv(span: SpanLike, env: NodeJS.ProcessEnv = process.env): void {
|
|
109
|
+
const { traceId, spanId } = span.spanContext();
|
|
110
|
+
const existingTraceId = env[TRACE_ID_ENV];
|
|
111
|
+
|
|
112
|
+
if (!existingTraceId || !isValidTraceId(existingTraceId)) {
|
|
113
|
+
env[TRACE_ID_ENV] = traceId;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
env[SPAN_ID_ENV] = spanId;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class TraceEnvStack<TSpan extends SpanLike = SpanLike> {
|
|
120
|
+
private stack: Array<{ key: string; span: TSpan }> = [];
|
|
121
|
+
|
|
122
|
+
constructor(private readonly env: NodeJS.ProcessEnv = process.env) {}
|
|
123
|
+
|
|
124
|
+
/** Returns the current top span (the one synced into env), if any. */
|
|
125
|
+
peek(): TSpan | undefined {
|
|
126
|
+
return this.stack[this.stack.length - 1]?.span;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
push(key: string, span: TSpan): void {
|
|
130
|
+
const existingIndex = this.stack.findIndex((entry) => entry.key === key);
|
|
131
|
+
if (existingIndex !== -1) {
|
|
132
|
+
this.stack.splice(existingIndex, 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.stack.push({ key, span });
|
|
136
|
+
syncTraceEnv(span, this.env);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pop(key: string): void {
|
|
140
|
+
const index = this.stack.findIndex((entry) => entry.key === key);
|
|
141
|
+
if (index === -1) return;
|
|
142
|
+
|
|
143
|
+
const wasTop = index === this.stack.length - 1;
|
|
144
|
+
this.stack.splice(index, 1);
|
|
145
|
+
|
|
146
|
+
if (wasTop) {
|
|
147
|
+
this.syncTop();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
syncTop(): void {
|
|
152
|
+
const top = this.stack[this.stack.length - 1];
|
|
153
|
+
if (!top) return;
|
|
154
|
+
syncTraceEnv(top.span, this.env);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
clear(): void {
|
|
158
|
+
this.stack = [];
|
|
159
|
+
}
|
|
160
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-telemetry-otel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenTelemetry (OTLP/HTTP) telemetry extension + helper APIs for pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package", "pi-extension", "opentelemetry", "otel", "tracing", "jaeger", "otlp"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"main": "./index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./index.ts",
|
|
11
|
+
"./extension": "./extensions/index.ts",
|
|
12
|
+
"./helpers": "./helpers/index.ts",
|
|
13
|
+
"./trace-env": "./lib/trace-env.ts",
|
|
14
|
+
"./runtime-registry": "./extensions/runtime-registry.ts",
|
|
15
|
+
"./span-context-registry": "./extensions/span-context-registry.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": ["index.ts", "extensions", "helpers", "lib", "README.md", "LICENSE"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "bun test"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@opentelemetry/api": "^1.9.0",
|
|
23
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.205.0",
|
|
24
|
+
"@opentelemetry/resources": "^2.0.1",
|
|
25
|
+
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
|
26
|
+
"@opentelemetry/semantic-conventions": "^1.37.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bun": "^1.3.2"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.0.0"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": ["./extensions/index.ts"]
|
|
39
|
+
}
|
|
40
|
+
}
|