pi-langfuse 1.5.18 → 1.5.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.5.18",
3
+ "version": "1.5.19",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
package/src/langfuse.ts CHANGED
@@ -463,25 +463,73 @@ function observationType(asType?: string): FallbackObservationType {
463
463
  * `LangfuseSpanProcessor.onStart(span, parentContext)`, reading them from the
464
464
  * OTel context that was active when the span was created. `propagateAttributes`
465
465
  * only seeds that context for the duration of its callback, so a child created
466
- * later — from a separate event handler, outside the callback — is written with
467
- * an empty `session.id`.
466
+ * later — from a separate event handler, outside the callback — is written
467
+ * without `session.id`, `langfuse.trace.name`, or `langfuse.trace.metadata.*`.
468
468
  *
469
- * That is invisible in the legacy data model, where the session lives on the
470
- * trace, but Langfuse v4 stores `session_id` per event row and aggregates a
471
- * session with `WHERE session_id != ''`. Unstamped children drop out of
472
- * `sumMap(cost_details)` and `sumMap(usage_details)`, which is why such
473
- * sessions report their trace count correctly but no cost and no usage at all.
469
+ * That is invisible in the legacy data model, where those live on the trace,
470
+ * but Langfuse v4 stores them per event row and filters and aggregates over
471
+ * those rows: a session is `WHERE session_id != ''`, and cost by trace name
472
+ * matches `trace_name` on the generation itself. Unstamped children drop out
473
+ * of `sumMap(cost_details)` and `sumMap(usage_details)`, which is why such
474
+ * sessions and trace-name filters report a correct trace count but no cost
475
+ * and no usage at all.
474
476
  *
475
- * Re-entering the propagated context for every child keeps the whole tree in
476
- * the session. The id is read back off the parent span rather than from
477
- * `state.currentSessionId`, so a child created outside an active session scope
478
- * still inherits whatever its parent was actually stamped with.
477
+ * Re-entering the propagated context for every child keeps the whole tree
478
+ * queryable. The attributes are read back off the parent span rather than
479
+ * from state, so a child created outside an active session scope still
480
+ * inherits whatever its parent was actually stamped with, and grandchildren
481
+ * inherit transitively.
482
+ *
483
+ * The re-entry runs on the OTel root context, not the ambient one.
484
+ * `propagateAttributes` starts from `context.active()`, merges `metadata`
485
+ * and `tags` with whatever that context already carries, and stamps the
486
+ * active span if there is one. Any attributes propagated by the caller —
487
+ * another instrumentation, a foreign `propagateAttributes` scope — would
488
+ * otherwise leak into the child or onto an unrelated span. The parent is
489
+ * still explicit: `observation.startObservation` passes the parent span
490
+ * context to the child, so a clean ambient context changes nothing else.
479
491
  */
480
- const OTEL_SESSION_ID_ATTRIBUTE = "session.id";
492
+ type PropagatedAttributes = Parameters<LangfuseRuntime["propagateAttributes"]>[0];
493
+
494
+ const PROPAGATED_STRING_ATTRIBUTES = {
495
+ sessionId: "session.id",
496
+ userId: "user.id",
497
+ traceName: "langfuse.trace.name",
498
+ version: "langfuse.version",
499
+ } as const;
500
+ const OTEL_TRACE_TAGS_ATTRIBUTE = "langfuse.trace.tags";
501
+ const OTEL_TRACE_METADATA_PREFIX = "langfuse.trace.metadata.";
502
+
503
+ function readPropagatedAttributes(observation: any): PropagatedAttributes {
504
+ const attributes: Record<string, unknown> = observation?.otelSpan?.attributes ?? {};
505
+ const propagated: PropagatedAttributes = {};
506
+
507
+ for (const [key, attribute] of Object.entries(PROPAGATED_STRING_ATTRIBUTES)) {
508
+ const value = attributes[attribute];
509
+ if (typeof value === "string" && value) {
510
+ propagated[key as keyof typeof PROPAGATED_STRING_ATTRIBUTES] = value;
511
+ }
512
+ }
513
+
514
+ const tags = attributes[OTEL_TRACE_TAGS_ATTRIBUTE];
515
+ if (Array.isArray(tags)) {
516
+ const validTags = tags.filter((tag): tag is string => typeof tag === "string" && tag.length > 0);
517
+ if (validTags.length > 0) {
518
+ propagated.tags = validTags;
519
+ }
520
+ }
481
521
 
482
- function readSessionId(observation: any): string | undefined {
483
- const value = observation?.otelSpan?.attributes?.[OTEL_SESSION_ID_ATTRIBUTE];
484
- return typeof value === "string" && value ? value : undefined;
522
+ const metadata: Record<string, string> = {};
523
+ for (const [key, value] of Object.entries(attributes)) {
524
+ if (key.startsWith(OTEL_TRACE_METADATA_PREFIX) && typeof value === "string") {
525
+ metadata[key.slice(OTEL_TRACE_METADATA_PREFIX.length)] = value;
526
+ }
527
+ }
528
+ if (Object.keys(metadata).length > 0) {
529
+ propagated.metadata = metadata;
530
+ }
531
+
532
+ return propagated;
485
533
  }
486
534
 
487
535
  function wrapObservation(
@@ -543,12 +591,19 @@ function wrapObservation(
543
591
  return observation.end();
544
592
  },
545
593
  startObservation(childName: string, childBody?: Record<string, unknown>, options?: { asType?: string }) {
546
- const inheritedSessionId = readSessionId(observation) ?? state.currentSessionId ?? undefined;
594
+ const inherited = readPropagatedAttributes(observation);
595
+ const fallbackSessionId = state.currentSessionId?.slice(0, 200);
596
+ if (!inherited.sessionId && fallbackSessionId) {
597
+ inherited.sessionId = fallbackSessionId;
598
+ }
547
599
  const propagate = runtime?.propagateAttributes;
548
600
  const createChild = () => observation.startObservation(childName, childBody, options);
549
- const child = inheritedSessionId && propagate
550
- ? propagate({ sessionId: inheritedSessionId.slice(0, 200) }, createChild)
551
- : createChild();
601
+ const createInheritingChild = propagate && Object.keys(inherited).length > 0
602
+ ? () => propagate(inherited, createChild)
603
+ : createChild;
604
+ const child = runtime?.withRootContext
605
+ ? runtime.withRootContext(createInheritingChild)
606
+ : createInheritingChild();
552
607
  return wrapObservation(child, store, childName, childBody, options?.asType, id);
553
608
  },
554
609
  setTraceIO(traceBody?: { input?: unknown; output?: unknown }) {
@@ -777,7 +832,7 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
777
832
  const [
778
833
  { BasicTracerProvider },
779
834
  { resources },
780
- { context },
835
+ { context, ROOT_CONTEXT },
781
836
  { AsyncHooksContextManager },
782
837
  { LangfuseSpanProcessor },
783
838
  tracing,
@@ -822,6 +877,7 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
822
877
  return wrapObservation(observation, restFallback, name, body, options?.asType);
823
878
  }) as unknown as LangfuseRuntime["startObservation"],
824
879
  propagateAttributes: tracing.propagateAttributes as unknown as LangfuseRuntime["propagateAttributes"],
880
+ withRootContext: (fn) => context.with(ROOT_CONTEXT, fn),
825
881
  scoreClient: new LangfuseClient({
826
882
  publicKey: state.config.publicKey,
827
883
  secretKey: state.config.secretKey,
package/src/types.ts CHANGED
@@ -87,12 +87,20 @@ export interface LangfuseRuntime {
87
87
  propagateAttributes: (
88
88
  params: {
89
89
  sessionId?: string;
90
+ userId?: string;
90
91
  traceName?: string;
92
+ version?: string;
91
93
  metadata?: Record<string, string>;
92
94
  tags?: string[];
93
95
  },
94
96
  fn: () => LangfuseObservation,
95
97
  ) => LangfuseObservation;
98
+ /**
99
+ * Runs `fn` on the OTel root context, so the observations it creates take
100
+ * their propagated attributes only from what is passed explicitly and not
101
+ * from whatever the caller's ambient context happens to carry.
102
+ */
103
+ withRootContext?: <T>(fn: () => T) => T;
96
104
  scoreClient: LangfuseScoreClient;
97
105
  spanProcessor?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
98
106
  tracerProvider?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };