Skip to content

Commit

Permalink
add workaround for nextjs cardinality issues (#957)
Browse files Browse the repository at this point in the history
  • Loading branch information
seemk authored Sep 27, 2024
1 parent 59bf484 commit ed51731
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 1 deletion.
70 changes: 70 additions & 0 deletions src/tracing/NextJsSpanProcessor.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return Promise.resolve();
}

shutdown(): Promise<void> {
return Promise.resolve();
}
}
15 changes: 14 additions & 1 deletion src/tracing/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
27 changes: 27 additions & 0 deletions test/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
118 changes: 118 additions & 0 deletions test/tracing/nextjsfix.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});

0 comments on commit ed51731

Please sign in to comment.