From ed51731190af1e51ceecbc86894866a15a72c2b4 Mon Sep 17 00:00:00 2001 From: Siim Kallas Date: Fri, 27 Sep 2024 13:44:22 +0300 Subject: [PATCH] add workaround for nextjs cardinality issues (#957) --- src/tracing/NextJsSpanProcessor.ts | 70 +++++++++++++++++ src/tracing/options.ts | 15 +++- src/types.ts | 1 + test/options.test.ts | 27 +++++++ test/tracing/nextjsfix.test.ts | 118 +++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/tracing/NextJsSpanProcessor.ts create mode 100644 test/tracing/nextjsfix.test.ts diff --git a/src/tracing/NextJsSpanProcessor.ts b/src/tracing/NextJsSpanProcessor.ts new file mode 100644 index 00000000..689449c4 --- /dev/null +++ b/src/tracing/NextJsSpanProcessor.ts @@ -0,0 +1,70 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Context } from '@opentelemetry/api'; +import { ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'; +import { SpanProcessor } from '@opentelemetry/sdk-trace-base'; + +// Workaround for high cardinality span names in Next.js +// https://github.com/vercel/next.js/issues/54694 +export class NextJsSpanProcessor implements SpanProcessor { + handleRequestSpan?: Span; + + onStart(span: Span, _parentContext: Context): void { + if (span.attributes['next.span_type'] === 'BaseServer.handleRequest') { + this.handleRequestSpan = span; + + const queryIndex = this.handleRequestSpan.name.indexOf('?'); + + if (queryIndex === -1) { + return; + } + + const name = this.handleRequestSpan.name.slice(0, queryIndex); + this.handleRequestSpan.updateName(name); + return; + } + + if (this.handleRequestSpan === undefined) { + return; + } + + if ( + span.attributes['next.span_name'] === 'resolve page components' && + span.parentSpanId === this.handleRequestSpan.spanContext().spanId && + typeof span.attributes['next.route'] === 'string' + ) { + const rsc = + this.handleRequestSpan.attributes['next.rsc'] === true ? 'rsc ' : ''; + const method = this.handleRequestSpan.attributes['http.method'] || ''; + const name = `${rsc}${method} ${span.attributes['next.route']}`; + this.handleRequestSpan.updateName(name); + } + } + + onEnd(span: ReadableSpan): void { + if (span === this.handleRequestSpan) { + this.handleRequestSpan = undefined; + } + } + + forceFlush(): Promise { + return Promise.resolve(); + } + + shutdown(): Promise { + return Promise.resolve(); + } +} diff --git a/src/tracing/options.ts b/src/tracing/options.ts index f3577d27..f8c843c7 100644 --- a/src/tracing/options.ts +++ b/src/tracing/options.ts @@ -45,6 +45,7 @@ import { import { SplunkBatchSpanProcessor } from './SplunkBatchSpanProcessor'; import { Resource } from '@opentelemetry/resources'; import type { ResourceFactory } from '../types'; +import { NextJsSpanProcessor } from './NextJsSpanProcessor'; type SpanExporterFactory = (options: Options) => SpanExporter | SpanExporter[]; @@ -324,7 +325,19 @@ export function defaultSpanProcessorFactory(options: Options): SpanProcessor[] { exporters = [exporters]; } - return exporters.map((exporter) => new SplunkBatchSpanProcessor(exporter)); + const nextJsFixEnabled = getEnvBoolean('SPLUNK_NEXTJS_FIX_ENABLED', false); + + const processors: SpanProcessor[] = []; + + if (nextJsFixEnabled) { + processors.push(new NextJsSpanProcessor()); + } + + for (const exporter of exporters) { + processors.push(new SplunkBatchSpanProcessor(exporter)); + } + + return processors; } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/types.ts b/src/types.ts index 5217afab..87514450 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,7 @@ export type EnvVarKey = | 'SPLUNK_INSTRUMENTATION_METRICS_ENABLED' | 'SPLUNK_METRICS_ENABLED' | 'SPLUNK_METRICS_ENDPOINT' + | 'SPLUNK_NEXTJS_FIX_ENABLED' | 'SPLUNK_PROFILER_CALL_STACK_INTERVAL' | 'SPLUNK_PROFILER_ENABLED' | 'SPLUNK_PROFILER_LOGS_ENDPOINT' diff --git a/test/options.test.ts b/test/options.test.ts index a8c02859..8a6e144d 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -53,6 +53,8 @@ import { } from '../src/tracing/options'; import * as utils from './utils'; import { ContainerDetector } from '@opentelemetry/resource-detector-container'; +import { SplunkBatchSpanProcessor } from '../src/tracing/SplunkBatchSpanProcessor'; +import { NextJsSpanProcessor } from '../src/tracing/NextJsSpanProcessor'; const assertVersion = (versionAttr) => { assert.equal(typeof versionAttr, 'string'); @@ -323,6 +325,31 @@ describe('options', () => { }); }); + describe('SPLUNK_NEXTJS_FIX_ENABLED', () => { + beforeEach(utils.cleanEnvironment); + + it('does not add a nextjs span processor by default', () => { + const options = _setDefaultOptions(); + const processors = options.spanProcessorFactory(options); + assert(Array.isArray(processors)); + + assert.deepStrictEqual(processors.length, 1); + assert(processors[0] instanceof SplunkBatchSpanProcessor); + }); + + it('enables nextjs span processor', () => { + process.env.SPLUNK_NEXTJS_FIX_ENABLED = 'true'; + + const options = _setDefaultOptions(); + const processors = options.spanProcessorFactory(options); + assert(Array.isArray(processors)); + + assert.deepStrictEqual(processors.length, 2); + assert(processors[0] instanceof NextJsSpanProcessor); + assert(processors[1] instanceof SplunkBatchSpanProcessor); + }); + }); + it('prefers service name from env resource info over the default service name', () => { process.env.OTEL_RESOURCE_ATTRIBUTES = 'service.name=foobar'; const options = _setDefaultOptions(); diff --git a/test/tracing/nextjsfix.test.ts b/test/tracing/nextjsfix.test.ts new file mode 100644 index 00000000..79caab5d --- /dev/null +++ b/test/tracing/nextjsfix.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; + +import { trace, context } from '@opentelemetry/api'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { NextJsSpanProcessor } from '../../src/tracing/NextJsSpanProcessor'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { Resource } from '@opentelemetry/resources'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; + +describe('Next.js span processor', () => { + const exporter = new InMemorySpanExporter(); + + const provider: NodeTracerProvider = new NodeTracerProvider({ + resource: new Resource({ + [ATTR_SERVICE_NAME]: 'nextjs', + }), + }); + + provider.addSpanProcessor(new NextJsSpanProcessor()); + provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); + + afterEach(() => { + exporter.reset(); + }); + + it('removes url query parameters if the route is not available', () => { + const tracer = provider.getTracer('test'); + tracer + .startSpan('rsc get /blog/23?asdf=foobar&x42=&_rsc=1iwkq', { + attributes: { + 'next.span_type': 'BaseServer.handleRequest', + }, + }) + .end(); + + const [span] = exporter.getFinishedSpans(); + assert.strictEqual(span.name, 'rsc get /blog/23'); + }); + + it('retains the url if no query parameters are present', () => { + const tracer = provider.getTracer('test'); + tracer + .startSpan('rsc get /blog/42', { + attributes: { + 'next.span_type': 'BaseServer.handleRequest', + }, + }) + .end(); + + const [span] = exporter.getFinishedSpans(); + assert.strictEqual(span.name, 'rsc get /blog/42'); + }); + + it('fetches the route from a child span', () => { + const tracer = provider.getTracer('test'); + const span = tracer.startSpan( + 'rsc get /blog/23?asdf=foobar&x42=&_rsc=1iwkq', + { + attributes: { + 'next.span_type': 'BaseServer.handleRequest', + 'http.method': 'GET', + 'next.rsc': true, + }, + } + ); + const ctx = trace.setSpan(context.active(), span); + tracer + .startSpan( + 'resolve page components', + { + attributes: { + 'next.span_name': 'resolve page components', + 'next.route': '/blog/[post]', + }, + }, + ctx + ) + .end(); + + span.end(); + + const [_child, parent] = exporter.getFinishedSpans(); + assert.strictEqual(parent.name, 'rsc GET /blog/[post]'); + }); + + it('does not modify other types of spans', () => { + const tracer = provider.getTracer('test'); + tracer + .startSpan('build component tree', { + attributes: { + 'next.span_type': 'NextNodeServer.createComponentTree', + }, + }) + .end(); + + const [span] = exporter.getFinishedSpans(); + assert.strictEqual(span.name, 'build component tree'); + }); +});