Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a span processor to improve Next.js cardinality issues #957

Merged
merged 1 commit into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
});
});
Loading