@narumitw/pi-langfuse 0.50.6 → 0.51.1

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/src/runtime.ts CHANGED
@@ -9,32 +9,62 @@ import {
9
9
  } from "@langfuse/tracing";
10
10
  import type { SpanProcessor } from "@opentelemetry/sdk-trace-base";
11
11
  import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
12
- import type { LangfuseConfig } from "./config.js";
12
+ import { DEFAULT_BASE_URL, type LangfuseConfig } from "./config.js";
13
+ import {
14
+ type CreateLangfuseRuntimeOptions,
15
+ createLangfuseRuntimeFromBackend,
16
+ getLangfuseRuntimeInternal,
17
+ type LangfuseRuntime,
18
+ type LangfuseRuntimeInternal,
19
+ } from "./runtime-core.js";
13
20
  import type { Observation, ObservationAttributes, ObservationType, TraceBackend } from "./tracing.js";
14
21
 
15
- const RUNTIME_KEY = Symbol.for("@narumitw/pi-langfuse/runtime/v1");
22
+ export type {
23
+ CreateLangfuseRuntimeOptions,
24
+ LangfuseRuntime,
25
+ LangfuseRuntimeConfig,
26
+ } from "./runtime-core.js";
27
+
28
+ const RUNTIME_KEY = Symbol.for("@narumitw/pi-langfuse/runtime/v2");
29
+ const LEGACY_RUNTIME_KEY = Symbol.for("@narumitw/pi-langfuse/runtime/v1");
30
+
31
+ interface ResolvedRuntimeConfig {
32
+ publicKey: string;
33
+ secretKey: string;
34
+ baseUrl: string;
35
+ environment?: string;
36
+ release?: string;
37
+ }
16
38
 
17
39
  interface SharedRuntime {
18
40
  fingerprint: string;
19
- backend: ProductionTraceBackend;
20
- shutdown: boolean;
41
+ runtime: LangfuseRuntimeInternal;
42
+ shutdown: true;
21
43
  }
22
44
 
23
45
  type GlobalWithRuntime = typeof globalThis & {
24
46
  [RUNTIME_KEY]?: Promise<SharedRuntime>;
47
+ [LEGACY_RUNTIME_KEY]?: unknown;
25
48
  };
26
49
 
27
50
  export interface RuntimeFactories {
28
- createProcessor(config: LangfuseConfig): SpanProcessor;
51
+ createProcessor(config: ResolvedRuntimeConfig): SpanProcessor;
29
52
  createProvider(processor: SpanProcessor): NodeTracerProvider;
30
53
  selectProvider(provider: NodeTracerProvider): void;
31
54
  }
32
55
 
33
56
  class ProductionObservation implements Observation {
34
- constructor(readonly native: LangfuseObservation) {}
57
+ readonly traceId: string;
58
+
59
+ constructor(
60
+ readonly native: LangfuseObservation,
61
+ private readonly secrets: readonly string[],
62
+ ) {
63
+ this.traceId = native.traceId;
64
+ }
35
65
 
36
66
  update(attributes: ObservationAttributes): Observation {
37
- const { sessionId, userId, ...observationAttributes } = attributes;
67
+ const { sessionId, userId, ...observationAttributes } = maskObservationAttributes(attributes, this.secrets);
38
68
  this.native.updateOtelSpanAttributes(observationAttributes as LangfuseObservationAttributes);
39
69
  applySessionId(this.native, sessionId);
40
70
  applyUserId(this.native, userId);
@@ -42,7 +72,10 @@ class ProductionObservation implements Observation {
42
72
  }
43
73
 
44
74
  updateTrace(attributes: ObservationAttributes): Observation {
45
- const { input, output, metadata, name, sessionId, userId, tags, version } = attributes;
75
+ const { input, output, metadata, name, sessionId, userId, tags, version } = maskObservationAttributes(
76
+ attributes,
77
+ this.secrets,
78
+ );
46
79
  if (input !== undefined || output !== undefined) {
47
80
  this.native.setTraceIO({ input, output });
48
81
  }
@@ -58,7 +91,10 @@ class ProductionObservation implements Observation {
58
91
  this.native.otelSpan.setAttribute(LangfuseOtelSpanAttributes.VERSION, version);
59
92
  }
60
93
  for (const [key, value] of Object.entries(metadata ?? {})) {
61
- this.native.otelSpan.setAttribute(`${LangfuseOtelSpanAttributes.TRACE_METADATA}.${key}`, String(value));
94
+ const serialized = serializeMetadataValue(value);
95
+ if (serialized !== undefined) {
96
+ this.native.otelSpan.setAttribute(`${LangfuseOtelSpanAttributes.TRACE_METADATA}.${key}`, serialized);
97
+ }
62
98
  }
63
99
  return this;
64
100
  }
@@ -73,22 +109,24 @@ class ProductionTraceBackend implements TraceBackend {
73
109
  constructor(
74
110
  private readonly provider: NodeTracerProvider,
75
111
  private readonly processor: SpanProcessor,
112
+ private readonly secrets: readonly string[],
76
113
  ) {}
77
114
 
78
115
  start(
79
116
  name: string,
80
117
  attributes: ObservationAttributes,
81
- options: { asType: ObservationType; parent?: Observation },
118
+ options: { asType: ObservationType; parent?: Observation; startTime?: Date },
82
119
  ): Observation {
83
- const { sessionId, userId, ...observationAttributes } = attributes;
120
+ const maskedName = maskSecretString(name, this.secrets);
121
+ const { sessionId, userId, ...observationAttributes } = maskObservationAttributes(attributes, this.secrets);
84
122
  const parent = options.parent;
85
123
  const native =
86
124
  parent instanceof ProductionObservation
87
- ? startChild(parent.native, name, observationAttributes, options.asType)
88
- : startRoot(name, observationAttributes, options.asType);
125
+ ? startChild(parent.native, maskedName, observationAttributes, options.asType, options.startTime)
126
+ : startRoot(maskedName, observationAttributes, options.asType, options.startTime);
89
127
  applySessionId(native, sessionId);
90
128
  applyUserId(native, userId);
91
- return new ProductionObservation(native);
129
+ return new ProductionObservation(native, this.secrets);
92
130
  }
93
131
 
94
132
  async forceFlush(): Promise<void> {
@@ -100,37 +138,58 @@ class ProductionTraceBackend implements TraceBackend {
100
138
  }
101
139
  }
102
140
 
141
+ export async function createLangfuseRuntime(options: CreateLangfuseRuntimeOptions = {}): Promise<LangfuseRuntime> {
142
+ return createSharedRuntime(resolveLangfuseRuntimeConfig(options), defaultFactories);
143
+ }
144
+
103
145
  export async function createProductionBackend(
104
146
  config: LangfuseConfig,
105
147
  factoryOverrides: Partial<RuntimeFactories> = {},
106
148
  ): Promise<TraceBackend> {
149
+ const runtime = await createSharedRuntime(resolveLangfuseRuntimeConfig({ config, env: false }), {
150
+ ...defaultFactories,
151
+ ...factoryOverrides,
152
+ });
153
+ return getLangfuseRuntimeInternal(runtime).backend;
154
+ }
155
+
156
+ async function createSharedRuntime(
157
+ config: ResolvedRuntimeConfig,
158
+ factories: RuntimeFactories,
159
+ ): Promise<LangfuseRuntime> {
107
160
  const globalRuntime = globalThis as GlobalWithRuntime;
108
161
  const fingerprint = configFingerprint(config);
109
162
  const existing = globalRuntime[RUNTIME_KEY];
163
+ const legacyRuntime = globalRuntime[LEGACY_RUNTIME_KEY];
164
+ if (legacyRuntime !== undefined && legacyRuntime !== existing) {
165
+ throw new Error("An older Langfuse runtime is already loaded; restart the process before enabling this version.");
166
+ }
110
167
  if (existing) {
111
- const runtime = await existing;
112
- if (runtime.shutdown) {
113
- throw new Error("Langfuse tracing was already shut down; restart Pi to enable it again.");
168
+ if (legacyRuntime === undefined) globalRuntime[LEGACY_RUNTIME_KEY] = existing;
169
+ const shared = await existing;
170
+ if (shared.runtime.closed) {
171
+ throw new Error("Langfuse tracing was already shut down; restart the process to enable it again.");
114
172
  }
115
- if (runtime.fingerprint !== fingerprint) {
116
- throw new Error("Langfuse configuration changed; restart Pi to apply the new credentials.");
173
+ if (shared.fingerprint !== fingerprint) {
174
+ throw new Error("Langfuse configuration changed; restart the process to apply the new credentials.");
117
175
  }
118
- return runtime.backend;
176
+ return shared.runtime;
119
177
  }
120
178
 
121
- const factories = { ...defaultFactories, ...factoryOverrides };
122
179
  const initializing = initializeRuntime(config, fingerprint, factories);
123
180
  globalRuntime[RUNTIME_KEY] = initializing;
181
+ globalRuntime[LEGACY_RUNTIME_KEY] = initializing;
124
182
  try {
125
- return (await initializing).backend;
183
+ return (await initializing).runtime;
126
184
  } catch (error) {
127
- delete globalRuntime[RUNTIME_KEY];
185
+ if (globalRuntime[RUNTIME_KEY] === initializing) delete globalRuntime[RUNTIME_KEY];
186
+ if (globalRuntime[LEGACY_RUNTIME_KEY] === initializing) delete globalRuntime[LEGACY_RUNTIME_KEY];
128
187
  throw error;
129
188
  }
130
189
  }
131
190
 
132
191
  async function initializeRuntime(
133
- config: LangfuseConfig,
192
+ config: ResolvedRuntimeConfig,
134
193
  fingerprint: string,
135
194
  factories: RuntimeFactories,
136
195
  ): Promise<SharedRuntime> {
@@ -143,15 +202,12 @@ async function initializeRuntime(
143
202
  await (provider?.shutdown() ?? processor.shutdown()).catch(() => undefined);
144
203
  throw error;
145
204
  }
146
- const backend = new ProductionTraceBackend(provider, processor);
147
- const runtime: SharedRuntime = { fingerprint, backend, shutdown: false };
148
- const originalShutdown = backend.shutdown.bind(backend);
149
- backend.shutdown = async () => {
150
- if (runtime.shutdown) return;
151
- runtime.shutdown = true;
152
- await originalShutdown();
153
- };
154
- return runtime;
205
+ const runtime = getLangfuseRuntimeInternal(
206
+ createLangfuseRuntimeFromBackend(
207
+ new ProductionTraceBackend(provider, processor, [config.publicKey, config.secretKey]),
208
+ ),
209
+ );
210
+ return { fingerprint, runtime, shutdown: true };
155
211
  }
156
212
 
157
213
  const defaultFactories: RuntimeFactories = {
@@ -174,6 +230,102 @@ const defaultFactories: RuntimeFactories = {
174
230
  selectProvider: setLangfuseTracerProvider,
175
231
  };
176
232
 
233
+ export function resolveLangfuseRuntimeConfig(options: CreateLangfuseRuntimeOptions): ResolvedRuntimeConfig {
234
+ const env = options.env === false ? undefined : (options.env ?? process.env);
235
+ const publicKey = requiredSetting("publicKey", options.config?.publicKey, env?.LANGFUSE_PUBLIC_KEY);
236
+ const secretKey = requiredSetting("secretKey", options.config?.secretKey, env?.LANGFUSE_SECRET_KEY);
237
+ const explicitBaseUrl = options.config?.baseUrl;
238
+ if (explicitBaseUrl !== undefined && !normalizeString(explicitBaseUrl)) {
239
+ throw new Error("Langfuse baseUrl must be a non-empty string.");
240
+ }
241
+ const rawBaseUrl = selectedSetting(explicitBaseUrl, env?.LANGFUSE_BASE_URL) ?? DEFAULT_BASE_URL;
242
+ const baseUrl = normalizeBaseUrl(rawBaseUrl);
243
+ if (!baseUrl) {
244
+ throw new Error("Langfuse baseUrl must use HTTP or HTTPS without credentials, a query, or a fragment.");
245
+ }
246
+ const environment = optionalSetting("environment", options.config?.environment, env?.LANGFUSE_TRACING_ENVIRONMENT);
247
+ if (environment && (environment.length > 40 || !/^(?!langfuse)[a-z0-9_-]+$/u.test(environment))) {
248
+ throw new Error(
249
+ "Langfuse environment must be at most 40 lowercase letters, numbers, hyphens, or underscores and must not start with langfuse.",
250
+ );
251
+ }
252
+ const release = optionalSetting("release", options.config?.release, env?.LANGFUSE_RELEASE);
253
+ return { publicKey, secretKey, baseUrl, ...(environment ? { environment } : {}), ...(release ? { release } : {}) };
254
+ }
255
+
256
+ function requiredSetting(name: string, explicit: string | undefined, environment: string | undefined): string {
257
+ const value = selectedSetting(explicit, environment);
258
+ if (!value) throw new Error(`Langfuse ${name} is required.`);
259
+ return value;
260
+ }
261
+
262
+ function optionalSetting(
263
+ name: string,
264
+ explicit: string | undefined,
265
+ environment: string | undefined,
266
+ ): string | undefined {
267
+ const selected = explicit !== undefined ? explicit : environment;
268
+ if (selected === undefined) return undefined;
269
+ const value = normalizeString(selected);
270
+ if (!value) throw new Error(`Langfuse ${name} must be a non-empty string.`);
271
+ return value;
272
+ }
273
+
274
+ function selectedSetting(explicit: string | undefined, environment: string | undefined): string | undefined {
275
+ if (explicit !== undefined) return normalizeString(explicit);
276
+ return normalizeString(environment);
277
+ }
278
+
279
+ function normalizeBaseUrl(value: string): string | undefined {
280
+ try {
281
+ const url = new URL(value);
282
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
283
+ if (url.username || url.password || url.search || url.hash) return undefined;
284
+ return url.toString().replace(/\/+$/, "");
285
+ } catch {
286
+ return undefined;
287
+ }
288
+ }
289
+
290
+ function normalizeString(value: unknown): string | undefined {
291
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
292
+ }
293
+
294
+ function serializeMetadataValue(value: unknown): string | undefined {
295
+ return typeof value === "string" ? value : JSON.stringify(value);
296
+ }
297
+
298
+ function maskObservationAttributes(
299
+ attributes: ObservationAttributes,
300
+ secrets: readonly string[],
301
+ ): ObservationAttributes {
302
+ return {
303
+ ...attributes,
304
+ ...(attributes.metadata ? { metadata: maskSecrets(attributes.metadata, secrets) as Record<string, unknown> } : {}),
305
+ ...(attributes.name ? { name: maskSecretString(attributes.name, secrets) } : {}),
306
+ ...(attributes.sessionId ? { sessionId: maskSecretString(attributes.sessionId, secrets) } : {}),
307
+ ...(attributes.userId ? { userId: maskSecretString(attributes.userId, secrets) } : {}),
308
+ ...(attributes.tags ? { tags: attributes.tags.map((tag) => maskSecretString(tag, secrets)) } : {}),
309
+ ...(attributes.statusMessage ? { statusMessage: maskSecretString(attributes.statusMessage, secrets) } : {}),
310
+ ...(attributes.model ? { model: maskSecretString(attributes.model, secrets) } : {}),
311
+ ...(attributes.version ? { version: maskSecretString(attributes.version, secrets) } : {}),
312
+ ...(attributes.modelParameters
313
+ ? {
314
+ modelParameters: Object.fromEntries(
315
+ Object.entries(attributes.modelParameters).map(([key, value]) => [
316
+ maskSecretString(key, secrets),
317
+ typeof value === "string" ? maskSecretString(value, secrets) : value,
318
+ ]),
319
+ ),
320
+ }
321
+ : {}),
322
+ };
323
+ }
324
+
325
+ function maskSecretString(value: string, secrets: readonly string[]): string {
326
+ return maskSecrets(value, secrets) as string;
327
+ }
328
+
177
329
  function applySessionId(observation: LangfuseObservation, sessionId: string | undefined): void {
178
330
  if (sessionId !== undefined) {
179
331
  observation.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId);
@@ -186,19 +338,25 @@ function applyUserId(observation: LangfuseObservation, userId: string | undefine
186
338
  }
187
339
  }
188
340
 
189
- function startRoot(name: string, attributes: ObservationAttributes, type: ObservationType): LangfuseObservation {
341
+ function startRoot(
342
+ name: string,
343
+ attributes: ObservationAttributes,
344
+ type: ObservationType,
345
+ startTime?: Date,
346
+ ): LangfuseObservation {
190
347
  if (type === "agent") {
191
- return startObservation(name, attributes as LangfuseObservationAttributes, { asType: "agent" });
348
+ return startObservation(name, attributes as LangfuseObservationAttributes, { asType: "agent", startTime });
192
349
  }
193
350
  if (type === "generation") {
194
351
  return startObservation(name, attributes as LangfuseObservationAttributes, {
195
352
  asType: "generation",
353
+ startTime,
196
354
  });
197
355
  }
198
356
  if (type === "tool") {
199
- return startObservation(name, attributes as LangfuseObservationAttributes, { asType: "tool" });
357
+ return startObservation(name, attributes as LangfuseObservationAttributes, { asType: "tool", startTime });
200
358
  }
201
- return startObservation(name, attributes as LangfuseObservationAttributes, { asType: "span" });
359
+ return startObservation(name, attributes as LangfuseObservationAttributes, { asType: "span", startTime });
202
360
  }
203
361
 
204
362
  function startChild(
@@ -206,28 +364,50 @@ function startChild(
206
364
  name: string,
207
365
  attributes: ObservationAttributes,
208
366
  type: ObservationType,
367
+ startTime?: Date,
209
368
  ): LangfuseObservation {
210
- if (type === "agent") {
211
- return parent.startObservation(name, attributes as LangfuseObservationAttributes, {
212
- asType: "agent",
369
+ if (startTime) {
370
+ const parentSpanContext = parent.otelSpan.spanContext();
371
+ if (type === "agent") {
372
+ return startObservation(name, attributes as LangfuseObservationAttributes, {
373
+ asType: "agent",
374
+ parentSpanContext,
375
+ startTime,
376
+ });
377
+ }
378
+ if (type === "generation") {
379
+ return startObservation(name, attributes as LangfuseObservationAttributes, {
380
+ asType: "generation",
381
+ parentSpanContext,
382
+ startTime,
383
+ });
384
+ }
385
+ if (type === "tool") {
386
+ return startObservation(name, attributes as LangfuseObservationAttributes, {
387
+ asType: "tool",
388
+ parentSpanContext,
389
+ startTime,
390
+ });
391
+ }
392
+ return startObservation(name, attributes as LangfuseObservationAttributes, {
393
+ asType: "span",
394
+ parentSpanContext,
395
+ startTime,
213
396
  });
214
397
  }
398
+ if (type === "agent") {
399
+ return parent.startObservation(name, attributes as LangfuseObservationAttributes, { asType: "agent" });
400
+ }
215
401
  if (type === "generation") {
216
- return parent.startObservation(name, attributes as LangfuseObservationAttributes, {
217
- asType: "generation",
218
- });
402
+ return parent.startObservation(name, attributes as LangfuseObservationAttributes, { asType: "generation" });
219
403
  }
220
404
  if (type === "tool") {
221
- return parent.startObservation(name, attributes as LangfuseObservationAttributes, {
222
- asType: "tool",
223
- });
405
+ return parent.startObservation(name, attributes as LangfuseObservationAttributes, { asType: "tool" });
224
406
  }
225
- return parent.startObservation(name, attributes as LangfuseObservationAttributes, {
226
- asType: "span",
227
- });
407
+ return parent.startObservation(name, attributes as LangfuseObservationAttributes, { asType: "span" });
228
408
  }
229
409
 
230
- function configFingerprint(config: LangfuseConfig): string {
410
+ function configFingerprint(config: ResolvedRuntimeConfig): string {
231
411
  return createHash("sha256")
232
412
  .update(
233
413
  `${config.publicKey}\0${config.secretKey}\0${config.baseUrl}\0${config.environment ?? ""}\0${config.release ?? ""}`,
@@ -256,7 +436,10 @@ function maskSecretValue(data: unknown, secrets: readonly string[], active: Weak
256
436
  return data.map((item) => maskSecretValue(item, secrets, active));
257
437
  }
258
438
  return Object.fromEntries(
259
- Object.entries(data).map(([key, value]) => [key, maskSecretValue(value, secrets, active)]),
439
+ Object.entries(data).map(([key, value]) => [
440
+ maskSecretString(key, secrets),
441
+ maskSecretValue(value, secrets, active),
442
+ ]),
260
443
  );
261
444
  } finally {
262
445
  active.delete(data);
package/src/sanitizer.ts CHANGED
@@ -19,9 +19,7 @@ function sanitize(value: unknown, active: WeakSet<object>, depth: number, budget
19
19
  return consume(value, budget);
20
20
  }
21
21
  if (typeof value === "string") {
22
- const bounded = truncateString(value, Math.min(MAX_STRING_LENGTH, budget.remaining));
23
- const redacted = bounded.replace(BASE64_DATA_URI, BASE64_DATA_URI_OMITTED);
24
- return consume(redacted, budget);
22
+ return consume(sanitizeString(value, Math.min(MAX_STRING_LENGTH, budget.remaining)), budget);
25
23
  }
26
24
  if (typeof value === "bigint") return consume(value.toString(), budget);
27
25
  if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
@@ -72,12 +70,13 @@ function sanitize(value: unknown, active: WeakSet<object>, depth: number, budget
72
70
  omitted = true;
73
71
  break;
74
72
  }
75
- budget.remaining -= keyBytes + 4;
73
+ const sanitizedKey = sanitizeString(key, keyBudget);
74
+ budget.remaining -= byteLength(sanitizedKey) + 4;
76
75
  const property = readProperty(record, key);
77
76
  if (redactData && key === "data") {
78
- output[key] = consume("[base64 omitted]", budget);
79
- } else if (!property.ok) output[key] = consume("[unreadable property]", budget);
80
- else output[key] = sanitize(property.value, active, depth + 1, budget);
77
+ output[sanitizedKey] = consume("[base64 omitted]", budget);
78
+ } else if (!property.ok) output[sanitizedKey] = consume("[unreadable property]", budget);
79
+ else output[sanitizedKey] = sanitize(property.value, active, depth + 1, budget);
81
80
  processed += 1;
82
81
  }
83
82
  } catch {
@@ -115,6 +114,10 @@ function consume<T>(value: T, budget: { remaining: number }): T | string {
115
114
  return value;
116
115
  }
117
116
 
117
+ function sanitizeString(value: string, maxBytes: number): string {
118
+ return truncateString(value, maxBytes).replace(BASE64_DATA_URI, BASE64_DATA_URI_OMITTED);
119
+ }
120
+
118
121
  function truncateString(value: string, maxBytes: number): string {
119
122
  const boundedPrefix = value.slice(0, maxBytes);
120
123
  if (boundedPrefix.length === value.length && byteLength(boundedPrefix) <= maxBytes) {
package/src/tracing.ts CHANGED
@@ -45,6 +45,7 @@ export interface ObservationAttributes {
45
45
  }
46
46
 
47
47
  export interface Observation {
48
+ readonly traceId?: string;
48
49
  update(attributes: ObservationAttributes): Observation;
49
50
  updateTrace?(attributes: ObservationAttributes): Observation;
50
51
  end(endTime?: number): Observation;
@@ -56,18 +57,22 @@ export interface TraceBackend {
56
57
  start(
57
58
  name: string,
58
59
  attributes: ObservationAttributes,
59
- options: { asType: ObservationType; parent?: Observation },
60
+ options: { asType: ObservationType; parent?: Observation; startTime?: Date },
60
61
  ): Observation;
61
62
  forceFlush(): Promise<void>;
62
63
  shutdown(): Promise<void>;
63
64
  }
64
65
 
65
- interface RecorderContext {
66
+ export interface TraceRecorderOptions {
66
67
  sessionId: string;
67
68
  userId?: string;
68
69
  cwd: string;
69
70
  mode: string;
70
71
  captureContent: boolean;
72
+ traceName?: string;
73
+ tags?: readonly string[];
74
+ metadata?: Readonly<Record<string, unknown>>;
75
+ onTraceId?: (traceId: string) => void;
71
76
  }
72
77
 
73
78
  interface ModelDescriptor {
@@ -97,6 +102,7 @@ interface BeginAgentInput {
97
102
  model?: ModelDescriptor;
98
103
  git?: GitMetadata;
99
104
  snapshot?: ContextSnapshot;
105
+ requestId?: string;
100
106
  }
101
107
 
102
108
  interface AttemptInput {
@@ -104,6 +110,7 @@ interface AttemptInput {
104
110
  }
105
111
 
106
112
  interface GenerationInput {
113
+ startedAt?: number;
107
114
  payload?: unknown;
108
115
  payloadStage?: "before_provider_request";
109
116
  model?: ModelDescriptor;
@@ -233,13 +240,13 @@ export class TraceRecorder {
233
240
 
234
241
  constructor(
235
242
  private readonly backend: TraceBackend,
236
- private readonly context: RecorderContext,
243
+ private readonly context: TraceRecorderOptions,
237
244
  ) {}
238
245
 
239
246
  private startObservation(
240
247
  name: string,
241
248
  attributes: ObservationAttributes,
242
- options: { asType: ObservationType; parent?: Observation },
249
+ options: { asType: ObservationType; parent?: Observation; startTime?: Date },
243
250
  ): Observation {
244
251
  const { sessionId, userId } = this.context;
245
252
  return this.backend.start(name, { ...attributes, sessionId, ...(userId ? { userId } : {}) }, options);
@@ -268,6 +275,7 @@ export class TraceRecorder {
268
275
  ...(hasItems(input.images) ? { images: input.images } : {}),
269
276
  });
270
277
  const metadata: Record<string, unknown> = {
278
+ ...customTraceMetadata(this.context.metadata),
271
279
  "pi.cwd": this.context.cwd,
272
280
  ...(input.git?.branch ? { "pi.git.branch": input.git.branch } : {}),
273
281
  ...(input.git?.commit ? { "pi.git.commit": input.git.commit } : {}),
@@ -275,6 +283,7 @@ export class TraceRecorder {
275
283
  "pi.mode": this.context.mode,
276
284
  ...(input.model?.id ? { "pi.model": input.model.id } : {}),
277
285
  ...(input.model?.provider ? { "pi.provider": input.model.provider } : {}),
286
+ ...(input.requestId ? { "pi.request.id": input.requestId } : {}),
278
287
  "pi.session.id": this.context.sessionId,
279
288
  "pi.trace.schema_version": Number(TRACE_SCHEMA_VERSION),
280
289
  ...snapshotMetadata("start", input.snapshot),
@@ -284,14 +293,22 @@ export class TraceRecorder {
284
293
 
285
294
  this.root = this.startObservation("pi.agent", attributes, { asType: "agent" });
286
295
  this.root.updateTrace?.({
287
- name: "pi.trace",
296
+ name: normalizeTraceName(this.context.traceName),
288
297
  sessionId: this.context.sessionId,
289
298
  ...(this.context.userId ? { userId: this.context.userId } : {}),
290
299
  version: TRACE_SCHEMA_VERSION,
291
300
  input: traceInput,
292
301
  metadata,
293
- tags: ["pi", ...(gitTag ? [gitTag] : [])],
302
+ tags: traceTags(this.context.tags, gitTag),
294
303
  });
304
+ const traceId = this.root.traceId;
305
+ if (traceId && this.context.onTraceId) {
306
+ try {
307
+ void Promise.resolve(this.context.onTraceId(traceId)).catch(() => undefined);
308
+ } catch {
309
+ // Host callbacks must not corrupt observation lifecycle state.
310
+ }
311
+ }
295
312
  }
296
313
 
297
314
  beginAttempt(input: AttemptInput = {}): void {
@@ -389,7 +406,11 @@ export class TraceRecorder {
389
406
  ...(Object.keys(requestMetadata).length > 0 ? { metadata: requestMetadata } : {}),
390
407
  version: TRACE_SCHEMA_VERSION,
391
408
  },
392
- { asType: "generation", parent: this.turn ?? this.attempt ?? this.root },
409
+ {
410
+ asType: "generation",
411
+ parent: this.turn ?? this.attempt ?? this.root,
412
+ ...(input.startedAt === undefined ? {} : { startTime: new Date(input.startedAt) }),
413
+ },
393
414
  );
394
415
  this.generation = {
395
416
  observation,
@@ -613,13 +634,8 @@ export class TraceRecorder {
613
634
  this.closeActiveTrace(statusMessage, snapshot, "interrupted");
614
635
  }
615
636
 
616
- async flush(): Promise<void> {
617
- await this.backend.forceFlush();
618
- }
619
-
620
- async shutdown(snapshot?: ContextSnapshot): Promise<void> {
621
- this.closeActiveTrace("Pi shut down before the active trace settled.", snapshot, "interrupted");
622
- await this.backend.shutdown();
637
+ dispose(statusMessage = "Pi session ended before the active trace settled.", snapshot?: ContextSnapshot): void {
638
+ this.closeActiveTrace(statusMessage, snapshot, "interrupted");
623
639
  }
624
640
 
625
641
  private closeActiveTrace(statusMessage?: string, snapshot?: ContextSnapshot, forcedOutcome?: Outcome): void {
@@ -755,6 +771,28 @@ export class TraceRecorder {
755
771
  }
756
772
  }
757
773
 
774
+ function normalizeTraceName(value: string | undefined): string {
775
+ return typeof value === "string" && value.trim() ? value.trim() : "pi.trace";
776
+ }
777
+
778
+ function traceTags(customTags: readonly string[] | undefined, gitTag: string | undefined): string[] {
779
+ const tags = ["pi", ...(gitTag ? [gitTag] : []), ...(customTags ?? [])];
780
+ return [...new Set(tags.map(sanitizeTraceTag).filter((tag): tag is string => tag !== undefined))];
781
+ }
782
+
783
+ function sanitizeTraceTag(value: unknown): string | undefined {
784
+ if (typeof value !== "string" || !value.trim()) return undefined;
785
+ const sanitized = sanitizeTraceValue(value.trim());
786
+ return typeof sanitized === "string" && sanitized.trim() ? sanitized.trim() : undefined;
787
+ }
788
+
789
+ function customTraceMetadata(metadata: Readonly<Record<string, unknown>> | undefined): Record<string, unknown> {
790
+ if (!metadata) return {};
791
+ const sanitized = sanitizeTraceValue(metadata);
792
+ if (!sanitized || typeof sanitized !== "object" || Array.isArray(sanitized)) return {};
793
+ return Object.fromEntries(Object.entries(sanitized).filter(([key]) => !key.startsWith("pi.")));
794
+ }
795
+
758
796
  function generationHttpMetadata(state: GenerationState): Record<string, unknown> {
759
797
  const finalStatus = state.statuses.at(-1);
760
798
  return {