From e6113f1858a9cc2089b55bfa905cf5f10153dc70 Mon Sep 17 00:00:00 2001 From: Din Date: Sat, 21 Dec 2024 18:16:05 +0500 Subject: [PATCH 1/5] wip --- src/sdk/tracing/index.ts | 53 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/sdk/tracing/index.ts b/src/sdk/tracing/index.ts index 3196905..75bef67 100644 --- a/src/sdk/tracing/index.ts +++ b/src/sdk/tracing/index.ts @@ -1,15 +1,29 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { baggageUtils } from "@opentelemetry/core"; -import { ProxyTracerProvider, Span, TracerProvider, context, diag, trace } from "@opentelemetry/api"; +import { + ProxyTracerProvider, + TracerProvider, + context, + diag, + trace, +} from "@opentelemetry/api"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { Instrumentation } from "@opentelemetry/instrumentation"; +import { Span } from "@opentelemetry/sdk-trace-base"; import { InitializeOptions } from "../interfaces"; import { ASSOCIATION_PROPERTIES_KEY, SPAN_PATH_KEY, } from "./tracing"; import { _configuration } from "../configuration"; -import { NodeTracerProvider, SimpleSpanProcessor, BatchSpanProcessor, BasicTracerProvider, SpanProcessor } from "@opentelemetry/sdk-trace-node"; +import { + NodeTracerProvider, + SimpleSpanProcessor, + BatchSpanProcessor, + BasicTracerProvider, + SpanProcessor, + ReadableSpan, +} from "@opentelemetry/sdk-trace-node"; import { registerInstrumentations } from "@opentelemetry/instrumentation"; import { AnthropicInstrumentation } from "@traceloop/instrumentation-anthropic"; import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai"; @@ -25,7 +39,13 @@ import { PineconeInstrumentation } from "@traceloop/instrumentation-pinecone"; import { LangChainInstrumentation } from "@traceloop/instrumentation-langchain"; import { ChromaDBInstrumentation } from "@traceloop/instrumentation-chromadb"; import { QdrantInstrumentation } from "@traceloop/instrumentation-qdrant"; -import { ASSOCIATION_PROPERTIES, ASSOCIATION_PROPERTIES_OVERRIDES, SPAN_INSTRUMENTATION_SOURCE, SPAN_PATH } from "./attributes"; +import { + ASSOCIATION_PROPERTIES, + ASSOCIATION_PROPERTIES_OVERRIDES, + OVERRIDE_PARENT_SPAN, + SPAN_INSTRUMENTATION_SOURCE, + SPAN_PATH, +} from "./attributes"; let _spanProcessor: SimpleSpanProcessor | BatchSpanProcessor | SpanProcessor; let openAIInstrumentation: OpenAIInstrumentation | undefined; @@ -173,6 +193,12 @@ const manuallyInitInstrumentations = ( } }; +const isNextSpan = (span: ReadableSpan) => { + return span.attributes['next.span_name'] !== undefined + || span.attributes['next.span_type'] !== undefined + || span.attributes['next.clientComponentLoadCount'] !== undefined; +} + /** * Initializes the Traceloop SDK. * Must be called once before any other SDK methods. @@ -230,12 +256,24 @@ export const startTracing = (options: InitializeOptions) => { ? new SimpleSpanProcessor(traceExporter) : new BatchSpanProcessor(traceExporter)); + const nextSpanIds = new Set(); + _spanProcessor.onStart = (span: Span) => { const spanPath = context.active().getValue(SPAN_PATH_KEY); if (spanPath) { span.setAttribute(SPAN_PATH, spanPath as string); } + if (span.parentSpanId && nextSpanIds.has(span.parentSpanId)) { + // FIXME: seems like this `if` block is never entered. + // `nextSpanIds` does get populated, but it seems like the parent span + // gets the `next` attributes later than the child span starts. + // + // Alternative solution with `onEnd` is not working either – first of all + // span is immutable in onEnd, but also the if is not entered there either. + span.setAttribute(OVERRIDE_PARENT_SPAN, true); + } + span.setAttribute(SPAN_INSTRUMENTATION_SOURCE, "javascript"); // This sets the properties only if the context has them @@ -255,6 +293,15 @@ export const startTracing = (options: InitializeOptions) => { } }; + const originalOnEnd = _spanProcessor.onEnd.bind(_spanProcessor); + _spanProcessor.onEnd = (span: ReadableSpan) => { + if (isNextSpan(span)) { + nextSpanIds.add(span.spanContext().spanId); + } else { + originalOnEnd(span); + } + }; + if (options.useExternalTracerProvider) { const globalProvider = trace.getTracerProvider(); let provider: TracerProvider; From 99980d5170ab40f93727a25d0edf2db662492578 Mon Sep 17 00:00:00 2001 From: Din Date: Sun, 22 Dec 2024 13:03:27 +0500 Subject: [PATCH 2/5] disable next.js excessive tracing by default --- package.json | 2 +- .../initialize-options.interface.ts | 11 ++++ src/sdk/tracing/index.ts | 62 ++++++++++++++----- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index b4f2044..c474b20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lmnr-ai/lmnr", - "version": "0.4.27", + "version": "0.4.28", "description": "TypeScript SDK for Laminar AI", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/sdk/interfaces/initialize-options.interface.ts b/src/sdk/interfaces/initialize-options.interface.ts index 1a0ae61..0ab1423 100644 --- a/src/sdk/interfaces/initialize-options.interface.ts +++ b/src/sdk/interfaces/initialize-options.interface.ts @@ -114,4 +114,15 @@ export interface InitializeOptions { * This is useful for advanced use cases where the user wants to manage the tracer provider themselves. */ useExternalTracerProvider?: boolean; + + /** + * Whether to preserve Next.js spans. Optional. + * Defaults to false. + * Next.js instrumentation is very verbose and can result in + * a lot of noise in the traces. By default, Laminar + * will ignore the Next.js spans (looking at the attributes like `next.span_name`) + * and set the topmost non-Next span as the root span in the trace. + * This option allows to preserve the Next.js spans. + */ + preserveNextJsSpans?: boolean; } diff --git a/src/sdk/tracing/index.ts b/src/sdk/tracing/index.ts index 75bef67..c8f8de5 100644 --- a/src/sdk/tracing/index.ts +++ b/src/sdk/tracing/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { baggageUtils } from "@opentelemetry/core"; import { + Context, ProxyTracerProvider, TracerProvider, context, @@ -61,6 +62,7 @@ let pineconeInstrumentation: PineconeInstrumentation | undefined; let chromadbInstrumentation: ChromaDBInstrumentation | undefined; let qdrantInstrumentation: QdrantInstrumentation | undefined; +const NUM_TRACKED_NEXT_SPANS = 10000; const instrumentations: Instrumentation[] = []; @@ -193,6 +195,12 @@ const manuallyInitInstrumentations = ( } }; +// Next.js instrumentation is very verbose and can result in +// a lot of noise in the traces. By default, Laminar +// will ignore the Next.js spans (looking at the attributes like `next.span_name`) +// and set the topmost non-Next span as the root span in the trace. +// Here's the set of possible attributes as of Next.js 15.1 +// See also: https://github.com/vercel/next.js/blob/790efc5941e41c32bb50cd915121209040ea432c/packages/next/src/server/lib/trace/tracer.ts#L297 const isNextSpan = (span: ReadableSpan) => { return span.attributes['next.span_name'] !== undefined || span.attributes['next.span_type'] !== undefined @@ -257,23 +265,26 @@ export const startTracing = (options: InitializeOptions) => { : new BatchSpanProcessor(traceExporter)); const nextSpanIds = new Set(); + // In the program runtime, the set may become very large, so every now and then + // we remove half of the elements from the set to keep it from growing too much. + // We use the fact that JS Set preserves insertion order to remove the oldest elements. + const addNextSpanId = (spanId: string) => { + if (nextSpanIds.size >= NUM_TRACKED_NEXT_SPANS) { + const toRemove = Array.from(nextSpanIds).slice(0, NUM_TRACKED_NEXT_SPANS / 2); + toRemove.forEach(id => nextSpanIds.delete(id)); + } + nextSpanIds.add(spanId); + }; + + const originalOnEnd = _spanProcessor.onEnd.bind(_spanProcessor); + const originalOnStart = _spanProcessor.onStart.bind(_spanProcessor); - _spanProcessor.onStart = (span: Span) => { + _spanProcessor.onStart = (span: Span, parentContext: Context) => { const spanPath = context.active().getValue(SPAN_PATH_KEY); if (spanPath) { span.setAttribute(SPAN_PATH, spanPath as string); } - if (span.parentSpanId && nextSpanIds.has(span.parentSpanId)) { - // FIXME: seems like this `if` block is never entered. - // `nextSpanIds` does get populated, but it seems like the parent span - // gets the `next` attributes later than the child span starts. - // - // Alternative solution with `onEnd` is not working either – first of all - // span is immutable in onEnd, but also the if is not entered there either. - span.setAttribute(OVERRIDE_PARENT_SPAN, true); - } - span.setAttribute(SPAN_INSTRUMENTATION_SOURCE, "javascript"); // This sets the properties only if the context has them @@ -291,17 +302,39 @@ export const startTracing = (options: InitializeOptions) => { } } } + // OVERRIDE_PARENT_SPAN makes the current span the root span in the trace. + // The backend looks for this attribute and deletes the parentSpanId if the + // attribute is present. We do that to make the topmost non-Next.js span + // the root span in the trace. + if (span.parentSpanId + && nextSpanIds.has(span.parentSpanId) + && !options.preserveNextJsSpans + ) { + span.setAttribute(OVERRIDE_PARENT_SPAN, true); + } + originalOnStart(span, parentContext); + // If we know by this time that this span is created by Next.js, + // we add it to the set of nextSpanIds. + if (isNextSpan(span) && !options.preserveNextJsSpans) { + addNextSpanId(span.spanContext().spanId); + } }; - const originalOnEnd = _spanProcessor.onEnd.bind(_spanProcessor); _spanProcessor.onEnd = (span: ReadableSpan) => { - if (isNextSpan(span)) { - nextSpanIds.add(span.spanContext().spanId); + // Sometimes we only have know that this span is a Next.js only at the end. + // We add it to the set of nextSpanIds in that case. Also, we do not call + // the original onEnd, so that we don't send it to the backend. + if ((isNextSpan(span) || nextSpanIds.has(span.spanContext().spanId)) && !options.preserveNextJsSpans) { + addNextSpanId(span.spanContext().spanId); } else { + // By default, we call the original onEnd. originalOnEnd(span); } }; + // This is an experimental workaround for the issue where Laminar fails + // to work when there is another tracer provider already initialized. + // See: https://github.com/lmnr-ai/lmnr/issues/231 if (options.useExternalTracerProvider) { const globalProvider = trace.getTracerProvider(); let provider: TracerProvider; @@ -316,6 +349,7 @@ export const startTracing = (options: InitializeOptions) => { throw new Error("The active tracer provider does not support adding a span processor"); } } else { + // Default behavior, if no external tracer provider is used. const provider = new NodeTracerProvider({ spanProcessors: [_spanProcessor], }); From 74af59012f207f7f1c4841905ad882c8fc6c29c7 Mon Sep 17 00:00:00 2001 From: Din Date: Mon, 23 Dec 2024 08:58:17 +0500 Subject: [PATCH 3/5] rename variables --- src/sdk/tracing/index.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/sdk/tracing/index.ts b/src/sdk/tracing/index.ts index c8f8de5..1595223 100644 --- a/src/sdk/tracing/index.ts +++ b/src/sdk/tracing/index.ts @@ -201,7 +201,7 @@ const manuallyInitInstrumentations = ( // and set the topmost non-Next span as the root span in the trace. // Here's the set of possible attributes as of Next.js 15.1 // See also: https://github.com/vercel/next.js/blob/790efc5941e41c32bb50cd915121209040ea432c/packages/next/src/server/lib/trace/tracer.ts#L297 -const isNextSpan = (span: ReadableSpan) => { +const isNextJsSpan = (span: ReadableSpan) => { return span.attributes['next.span_name'] !== undefined || span.attributes['next.span_type'] !== undefined || span.attributes['next.clientComponentLoadCount'] !== undefined; @@ -264,16 +264,16 @@ export const startTracing = (options: InitializeOptions) => { ? new SimpleSpanProcessor(traceExporter) : new BatchSpanProcessor(traceExporter)); - const nextSpanIds = new Set(); + const nextJsSpanIds = new Set(); // In the program runtime, the set may become very large, so every now and then // we remove half of the elements from the set to keep it from growing too much. // We use the fact that JS Set preserves insertion order to remove the oldest elements. - const addNextSpanId = (spanId: string) => { - if (nextSpanIds.size >= NUM_TRACKED_NEXT_SPANS) { - const toRemove = Array.from(nextSpanIds).slice(0, NUM_TRACKED_NEXT_SPANS / 2); - toRemove.forEach(id => nextSpanIds.delete(id)); + const addNextJsSpanId = (spanId: string) => { + if (nextJsSpanIds.size >= NUM_TRACKED_NEXT_SPANS) { + const toRemove = Array.from(nextJsSpanIds).slice(0, NUM_TRACKED_NEXT_SPANS / 2); + toRemove.forEach(id => nextJsSpanIds.delete(id)); } - nextSpanIds.add(spanId); + nextJsSpanIds.add(spanId); }; const originalOnEnd = _spanProcessor.onEnd.bind(_spanProcessor); @@ -307,7 +307,7 @@ export const startTracing = (options: InitializeOptions) => { // attribute is present. We do that to make the topmost non-Next.js span // the root span in the trace. if (span.parentSpanId - && nextSpanIds.has(span.parentSpanId) + && nextJsSpanIds.has(span.parentSpanId) && !options.preserveNextJsSpans ) { span.setAttribute(OVERRIDE_PARENT_SPAN, true); @@ -315,8 +315,8 @@ export const startTracing = (options: InitializeOptions) => { originalOnStart(span, parentContext); // If we know by this time that this span is created by Next.js, // we add it to the set of nextSpanIds. - if (isNextSpan(span) && !options.preserveNextJsSpans) { - addNextSpanId(span.spanContext().spanId); + if (isNextJsSpan(span) && !options.preserveNextJsSpans) { + addNextJsSpanId(span.spanContext().spanId); } }; @@ -324,8 +324,8 @@ export const startTracing = (options: InitializeOptions) => { // Sometimes we only have know that this span is a Next.js only at the end. // We add it to the set of nextSpanIds in that case. Also, we do not call // the original onEnd, so that we don't send it to the backend. - if ((isNextSpan(span) || nextSpanIds.has(span.spanContext().spanId)) && !options.preserveNextJsSpans) { - addNextSpanId(span.spanContext().spanId); + if ((isNextJsSpan(span) || nextJsSpanIds.has(span.spanContext().spanId)) && !options.preserveNextJsSpans) { + addNextJsSpanId(span.spanContext().spanId); } else { // By default, we call the original onEnd. originalOnEnd(span); From 2e3e5c8fab93d6502145f36786ea6ff2a2aa439c Mon Sep 17 00:00:00 2001 From: Din Date: Mon, 23 Dec 2024 19:30:13 +0500 Subject: [PATCH 4/5] add unit tests --- src/laminar.ts | 4 +- src/sdk/configuration/index.ts | 4 + .../initialize-options.interface.ts | 6 + src/sdk/tracing/attributes.ts | 1 + src/sdk/tracing/index.ts | 10 +- test/nextjs.test.ts | 145 ++++++++++++++++++ test/tracing.test.ts | 12 +- 7 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 test/nextjs.test.ts diff --git a/src/laminar.ts b/src/laminar.ts index 7b5a99e..f1b2f10 100644 --- a/src/laminar.ts +++ b/src/laminar.ts @@ -48,7 +48,7 @@ interface LaminarInitializeProps { grpcPort?: number; instrumentModules?: InitializeOptions["instrumentModules"]; useExternalTracerProvider?: boolean; - _spanProcessor?: SpanProcessor; + preserveNextJsSpans?: boolean; } export class Laminar { @@ -113,6 +113,7 @@ export class Laminar { grpcPort, instrumentModules, useExternalTracerProvider, + preserveNextJsSpans, }: LaminarInitializeProps) { let key = projectApiKey ?? process.env.LMNR_PROJECT_API_KEY; @@ -149,6 +150,7 @@ export class Laminar { instrumentModules, disableBatch: false, useExternalTracerProvider, + preserveNextJsSpans, }); } diff --git a/src/sdk/configuration/index.ts b/src/sdk/configuration/index.ts index 26db08c..8b7ff56 100644 --- a/src/sdk/configuration/index.ts +++ b/src/sdk/configuration/index.ts @@ -66,3 +66,7 @@ const logLevelToOtelLogLevel = ( return DiagLogLevel.ERROR; } }; + +export const _resetConfiguration = () => { + _configuration = undefined; +}; diff --git a/src/sdk/interfaces/initialize-options.interface.ts b/src/sdk/interfaces/initialize-options.interface.ts index 0ab1423..a4e43d4 100644 --- a/src/sdk/interfaces/initialize-options.interface.ts +++ b/src/sdk/interfaces/initialize-options.interface.ts @@ -125,4 +125,10 @@ export interface InitializeOptions { * This option allows to preserve the Next.js spans. */ preserveNextJsSpans?: boolean; + + /** + * Whether to reset the configuration. Optional. + * Defaults to false. + */ + _resetConfiguration?: boolean; } diff --git a/src/sdk/tracing/attributes.ts b/src/sdk/tracing/attributes.ts index 634486c..f7e2584 100644 --- a/src/sdk/tracing/attributes.ts +++ b/src/sdk/tracing/attributes.ts @@ -6,6 +6,7 @@ export const SPAN_TYPE = "lmnr.span.type"; export const SPAN_PATH = "lmnr.span.path"; export const SPAN_INSTRUMENTATION_SOURCE = "lmnr.span.instrumentation_source"; export const OVERRIDE_PARENT_SPAN = "lmnr.internal.override_parent_span"; +export const EXTRACTED_FROM_NEXT_JS = "lmnr.span.extracted_from.next_js"; export const ASSOCIATION_PROPERTIES = "lmnr.association.properties"; export const SESSION_ID = "lmnr.association.properties.session_id"; diff --git a/src/sdk/tracing/index.ts b/src/sdk/tracing/index.ts index 1595223..6507fa3 100644 --- a/src/sdk/tracing/index.ts +++ b/src/sdk/tracing/index.ts @@ -5,7 +5,6 @@ import { ProxyTracerProvider, TracerProvider, context, - diag, trace, } from "@opentelemetry/api"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; @@ -43,6 +42,7 @@ import { QdrantInstrumentation } from "@traceloop/instrumentation-qdrant"; import { ASSOCIATION_PROPERTIES, ASSOCIATION_PROPERTIES_OVERRIDES, + EXTRACTED_FROM_NEXT_JS, OVERRIDE_PARENT_SPAN, SPAN_INSTRUMENTATION_SOURCE, SPAN_PATH, @@ -195,6 +195,8 @@ const manuallyInitInstrumentations = ( } }; +const NEXT_JS_SPAN_ATTRIBUTES = ['next.span_name', 'next.span_type', 'next.clientComponentLoadCount', 'next.page', 'next.rsc', 'next.route', 'next.segment']; + // Next.js instrumentation is very verbose and can result in // a lot of noise in the traces. By default, Laminar // will ignore the Next.js spans (looking at the attributes like `next.span_name`) @@ -202,9 +204,7 @@ const manuallyInitInstrumentations = ( // Here's the set of possible attributes as of Next.js 15.1 // See also: https://github.com/vercel/next.js/blob/790efc5941e41c32bb50cd915121209040ea432c/packages/next/src/server/lib/trace/tracer.ts#L297 const isNextJsSpan = (span: ReadableSpan) => { - return span.attributes['next.span_name'] !== undefined - || span.attributes['next.span_type'] !== undefined - || span.attributes['next.clientComponentLoadCount'] !== undefined; + return NEXT_JS_SPAN_ATTRIBUTES.some(attr => span.attributes[attr] !== undefined); } /** @@ -311,6 +311,8 @@ export const startTracing = (options: InitializeOptions) => { && !options.preserveNextJsSpans ) { span.setAttribute(OVERRIDE_PARENT_SPAN, true); + // to indicate that this span was originally parented by a Next.js span + span.setAttribute(EXTRACTED_FROM_NEXT_JS, true); } originalOnStart(span, parentContext); // If we know by this time that this span is created by Next.js, diff --git a/test/nextjs.test.ts b/test/nextjs.test.ts new file mode 100644 index 0000000..0fc66d6 --- /dev/null +++ b/test/nextjs.test.ts @@ -0,0 +1,145 @@ +import { afterEach, after, beforeEach, describe, it } from "node:test"; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { + observe, +} from "../src/index"; +import assert from "node:assert/strict"; +import { initializeTracing } from "../src/sdk/configuration"; +import { context, trace } from "@opentelemetry/api"; +import { _resetConfiguration } from "../src/sdk/configuration"; + +const isThenable = ( + promise: Promise | T +): promise is Promise => + promise !== null + && typeof promise === 'object' + && 'then' in promise + && typeof promise.then === 'function' + +// trying to make this close to +// https://github.com/vercel/next.js/blob/790efc5941e41c32bb50cd915121209040ea432c/packages/next/src/server/lib/trace/tracer.ts#L297 +const startNextJsSpan = (innerFn: (...args: any[]) => any, ...args: any[]) => { + const options = { + attributes: { + 'next.span_name': 'test', + 'next.span_type': 'NextNodeServer.findPageComponents' + } + } + return context.with(context.active(), () => { + trace + .getTracer('next.js', '0.0.1') + .startActiveSpan('test_next_js', options, (span) => { + const result = innerFn(...args); + if (isThenable(result)) { + result.then(() => span.end()); + } else { + span.end(); + } + }); + }); +} + +describe("nextjs", () => { + const exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + + beforeEach(() => { + // This only uses underlying OpenLLMetry initialization, not Laminar's + // initialization, but this is sufficient for testing. + // Laminar.initialize() is tested in the other suite. + _resetConfiguration(); + initializeTracing({ processor, exporter }); + }); + + afterEach(() => { + exporter.reset(); + }); + + after(() => { + processor.shutdown(); + trace.disable(); + context.disable(); + }); + + it("doesn't send any spans if the span is Next.js", async () => { + const fn = (a: number, b: number) => a + b; + startNextJsSpan(fn, 1, 2); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + }); + + it("doesn't send any spans when all nested spans are Next.js", async () => { + const fn = (a: number, b: number) => a + b; + const outer = () => startNextJsSpan(fn, 1, 2); + startNextJsSpan(outer); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + }); + + it("sends the inner span if it is not Next.js", async () => { + const fn = (a: number, b: number) => a + b; + const observed = async () => await observe({name: "test"}, fn, 1, 2); + startNextJsSpan(observed); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].name, "test"); + assert.strictEqual(spans[0].attributes['lmnr.span.input'], JSON.stringify([1, 2])); + assert.strictEqual(spans[0].attributes['lmnr.span.output'], "3"); + + assert.strictEqual(spans[0].attributes['lmnr.association.properties.label.endpoint'], undefined); + assert.strictEqual(spans[0].attributes['lmnr.span.instrumentation_source'], "javascript"); + assert.strictEqual(spans[0].attributes['lmnr.internal.override_parent_span'], true); + }); +}); + +describe("nextjs.preserveNextJsSpans", () => { + const exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + + beforeEach(() => { + // This only uses underlying OpenLLMetry initialization, not Laminar's + // initialization, but this is sufficient for testing. + // Laminar.initialize() is tested in the other suite. + _resetConfiguration(); + initializeTracing({ processor, exporter, preserveNextJsSpans: true }); + }); + + afterEach(() => { + exporter.reset(); + }); + + after(() => { + processor.shutdown(); + trace.disable(); + context.disable(); + }); + + it("preserves the Next.js span", async () => { + const fn = (a: number, b: number) => a + b; + startNextJsSpan(fn, 1, 2); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].name, "test_next_js"); + }); + + it("preserves the Next.js span and its children", async () => { + const fn = (a: number, b: number) => a + b; + const inner = observe({name: "test"}, fn, 1, 2); + const outer = () => startNextJsSpan(async () => await inner); + startNextJsSpan(outer); + + const spans = exporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + const nextJsSpan = spans.find(span => span.name === "test_next_js"); + const observedSpan = spans.find(span => span.name === "test"); + assert.strictEqual(observedSpan?.attributes['lmnr.span.input'], JSON.stringify([1, 2])); + assert.strictEqual(observedSpan?.attributes['lmnr.span.output'], "3"); + }); +}); diff --git a/test/tracing.test.ts b/test/tracing.test.ts index f9cf726..05bf975 100644 --- a/test/tracing.test.ts +++ b/test/tracing.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, it, mock } from "node:test"; +import { afterEach, after, beforeEach, describe, it } from "node:test"; import { InMemorySpanExporter, SimpleSpanProcessor, @@ -12,7 +12,8 @@ import { withTracingLevel, } from "../src/index"; import assert from "node:assert/strict"; -import { initializeTracing } from "../src/sdk/configuration"; +import { context, trace } from "@opentelemetry/api"; +import { _resetConfiguration, initializeTracing } from "../src/sdk/configuration"; describe("tracing", () => { const exporter = new InMemorySpanExporter(); @@ -22,6 +23,7 @@ describe("tracing", () => { // This only uses underlying OpenLLMetry initialization, not Laminar's // initialization, but this is sufficient for testing. // Laminar.initialize() is tested in the other suite. + _resetConfiguration(); initializeTracing({ processor, exporter }); }); @@ -30,6 +32,12 @@ describe("tracing", () => { exporter.reset(); }); + after(() => { + processor.shutdown(); + trace.disable(); + context.disable(); + }); + it("observes a wrapped function", async () => { const fn = (a: number, b: number) => a + b; const result = await observe({name: "test"}, fn, 1, 2); From 7b5487dd33cbe2bd5b665a88a8d284ebbb6b60ed Mon Sep 17 00:00:00 2001 From: Din Date: Tue, 24 Dec 2024 11:38:09 +0500 Subject: [PATCH 5/5] fix bug where observe didn't record inputs in nextjs --- src/sdk/tracing/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/sdk/tracing/index.ts b/src/sdk/tracing/index.ts index 6507fa3..8f5a4f8 100644 --- a/src/sdk/tracing/index.ts +++ b/src/sdk/tracing/index.ts @@ -48,6 +48,9 @@ import { SPAN_PATH, } from "./attributes"; +// for doc comment: +import { withTracingLevel } from "../../decorators"; + let _spanProcessor: SimpleSpanProcessor | BatchSpanProcessor | SpanProcessor; let openAIInstrumentation: OpenAIInstrumentation | undefined; let anthropicInstrumentation: AnthropicInstrumentation | undefined; @@ -365,7 +368,14 @@ export const startTracing = (options: InitializeOptions) => { export const shouldSendTraces = () => { if (!_configuration) { - return false; + /** + * We've only seen this happen in Next.js where apparently + * the initialization in `instrumentation.ts` somehow does not + * respect `Object.freeze`. Unlike original OpenLLMetry/Traceloop, + * we return true here, because we have other mechanisms + * {@link withTracingLevel} to disable tracing inputs and outputs. + */ + return true; } if (