From c496b363752e800973ede6483630845312eb5fc0 Mon Sep 17 00:00:00 2001 From: Bastian Krol Date: Wed, 12 Jun 2024 08:11:43 +0200 Subject: [PATCH] feat: create bootstrap span on demand (DASH0_BOOTSTRAP_SPAN.) --- README.md | 5 ++ src/init.ts | 12 +++++ test/integration/test.ts | 61 +++++++++++++++++----- test/util/expectMatchingSpan.ts | 34 +++++++------ test/util/findMatchingSpans.ts | 90 +++++++++++++++++++++++++-------- 5 files changed, 154 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 904f352..49e756c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ If no `OTEL_SERVICE_NAME` has been set, a service name is automatically derived (if it is present) as `${packageJson.name}@${packageJson.version}`. This can be disabled either by setting `OTEL_SERVICE_NAME` or by setting `DASH0_AUTOMATIC_SERVICE_NAME=false`. +### DASH0_BOOTSTRAP_SPAN + +If set to a non-empty string, the distribution will create a span immediately at startup with the span name set to the +value of DASH0_BOOTSTRAP_SPAN. + ### DASH0_DEBUG Additional debug logs can be enabled by setting `DASH0_DEBUG=true`. diff --git a/src/init.ts b/src/init.ts index adc7ba1..5f56861 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. // SPDX-License-Identifier: Apache-2.0 +import { SpanKind, trace } from '@opentelemetry/api'; import { getNodeAutoInstrumentations, getResourceDetectors } from '@opentelemetry/auto-instrumentations-node'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; @@ -87,3 +88,14 @@ sdk.start(); if (process.env.DASH0_DEBUG) { console.log('Dash0 OpenTelemetry distribution for Node.js: NodeSDK started.'); } + +setTimeout(() => { + if (process.env.DASH0_BOOTSTRAP_SPAN != null) { + trace + .getTracer('dash0-nodejs-distribution') + .startSpan(process.env.DASH0_BOOTSTRAP_SPAN, { kind: SpanKind.INTERNAL }) + .end(); + } + // Note: Calling startSpan immediately after sdk.start() will result in the span not actually being created, probably + // because the tracer is not ready yet. For now we use a delay of 1s to make this somewhat reliable. +}, 1000); diff --git a/test/integration/test.ts b/test/integration/test.ts index 8f4f514..5ad3020 100644 --- a/test/integration/test.ts +++ b/test/integration/test.ts @@ -34,7 +34,7 @@ describe('attach', () => { expectedDistroVersion = JSON.parse(String(await readFile('package.json'))).version; }); - describe('tracing', () => { + describe('basic tracing', () => { let appUnderTest: ChildProcessWrapper; before(async () => { @@ -49,7 +49,7 @@ describe('attach', () => { it('should attach via --require and capture spans', async () => { await waitUntil(async () => { - const telemetry = await waitForTelemetry(); + const telemetry = await sendRequestAndWaitForTraceData(); expectMatchingSpan( telemetry.traces, [ @@ -59,7 +59,7 @@ describe('attach', () => { resource => expectResourceAttribute(resource, 'telemetry.distro.version', expectedDistroVersion), ], [ - span => expect(span.kind).to.equal(SpanKind.SERVER), + span => expect(span.kind).to.equal(SpanKind.SERVER, 'span kind should be server'), span => expectSpanAttribute(span, 'http.route', '/ohai'), ], ); @@ -83,12 +83,12 @@ describe('attach', () => { it('should attach via --require and detect the pod uid', async () => { await waitUntil(async () => { - const telemetry = await waitForTelemetry(); + const telemetry = await sendRequestAndWaitForTraceData(); expectMatchingSpan( telemetry.traces, [resource => expectResourceAttribute(resource, 'k8s.pod.uid', 'f57400dc-94ce-4806-a52e-d2726f448f15')], [ - span => expect(span.kind).to.equal(SpanKind.SERVER), + span => expect(span.kind).to.equal(SpanKind.SERVER, 'span kind should be server'), span => expectSpanAttribute(span, 'http.route', '/ohai'), ], ); @@ -111,7 +111,7 @@ describe('attach', () => { it('should attach via --require and derive a service name from the package.json file', async () => { await waitUntil(async () => { - const telemetry = await waitForTelemetry(); + const telemetry = await sendRequestAndWaitForTraceData(); expectMatchingSpan( telemetry.traces, [ @@ -119,7 +119,7 @@ describe('attach', () => { expectResourceAttribute(resource, 'service.name', 'dash0-app-under-test-express-typescript@1.0.0'), ], [ - span => expect(span.kind).to.equal(SpanKind.SERVER), + span => expect(span.kind).to.equal(SpanKind.SERVER, 'span kind should be server'), span => expectSpanAttribute(span, 'http.route', '/ohai'), ], ); @@ -127,6 +127,37 @@ describe('attach', () => { }); }); + describe('bootstrap span', () => { + let appUnderTest: ChildProcessWrapper; + + before(async () => { + const appConfiguration = defaultAppConfiguration(appPort); + appConfiguration.env!.DASH0_BOOTSTRAP_SPAN = 'Dash0 Test Bootstrap Span'; + appUnderTest = new ChildProcessWrapper(appConfiguration); + await appUnderTest.start(); + }); + + after(async () => { + await appUnderTest.stop(); + }); + + it('should create an internal span on bootstrap', async () => { + await waitUntil(async () => { + const telemetry = await waitForTraceData(); + expectMatchingSpan( + telemetry.traces, + [ + resource => expectResourceAttribute(resource, 'telemetry.sdk.name', 'opentelemetry'), + resource => expectResourceAttribute(resource, 'telemetry.sdk.language', 'nodejs'), + resource => expectResourceAttribute(resource, 'telemetry.distro.name', 'dash0-nodejs'), + resource => expectResourceAttribute(resource, 'telemetry.distro.version', expectedDistroVersion), + ], + [span => expect(span.name).to.equal('Dash0 Test Bootstrap Span')], + ); + }); + }); + }); + describe('print spans to file', () => { let appUnderTest: ChildProcessWrapper; const spanFilename = join(__dirname, 'spans.json'); @@ -185,7 +216,7 @@ describe('attach', () => { resourceAttributes => expect(resourceAttributes['telemetry.distro.version']).to.equal(expectedDistroVersion), ], - [spanAttributes => expect(spanAttributes.kind).to.equal(SpanKind.SERVER)], + [spanAttributes => expect(spanAttributes.kind).to.equal(SpanKind.SERVER, 'span kind should be server')], [spanAttributes => expect(spanAttributes['http.route']).to.equal('/ohai')], ); }); @@ -217,12 +248,9 @@ describe('attach', () => { }); }); - async function waitForTelemetry() { + async function sendRequestAndWaitForTraceData() { await sendRequestAndVerifyResponse(); - if (!(await collector().hasTraces())) { - throw new Error('The collector never received any spans.'); - } - return await collector().fetchTelemetry(); + return waitForTraceData(); } async function sendRequestAndVerifyResponse() { @@ -232,6 +260,13 @@ describe('attach', () => { expect(responsePayload).to.deep.equal({ message: 'We make Observability easy for every developer.' }); } + async function waitForTraceData() { + if (!(await collector().hasTraces())) { + throw new Error('The collector never received any spans.'); + } + return await collector().fetchTelemetry(); + } + async function verifyFileHasBeenCreated(filename: string): Promise { let file; try { diff --git a/test/util/expectMatchingSpan.ts b/test/util/expectMatchingSpan.ts index 20c5d15..e1d1ede 100644 --- a/test/util/expectMatchingSpan.ts +++ b/test/util/expectMatchingSpan.ts @@ -4,15 +4,15 @@ import { ExportTraceServiceRequest } from '../collector/types/opentelemetry/proto/collector/trace/v1/trace_service'; import { Resource } from '../collector/types/opentelemetry/proto/resource/v1/resource'; import { Span } from '../collector/types/opentelemetry/proto/trace/v1/trace'; -import { Expectation, findMatchingSpans, findMatchingSpansInFileDump } from './findMatchingSpans'; +import { Expectation, findMatchingSpans, findMatchingSpansInFileDump, MatchingSpansResult } from './findMatchingSpans'; export function expectMatchingSpan( traceDataItems: ExportTraceServiceRequest[], resourceExpectations: Expectation[], spanExpectations: Expectation[], ): Span { - const { matchingSpans, lastError } = findMatchingSpans(traceDataItems, resourceExpectations, spanExpectations); - return processFindSpanResult(matchingSpans, lastError); + const matchResult = findMatchingSpans(traceDataItems, resourceExpectations, spanExpectations); + return processFindSpanResult(matchResult); } export function expectMatchingSpanInFileDump( @@ -21,29 +21,33 @@ export function expectMatchingSpanInFileDump( spanExpectations: Expectation[], spanAttributeExpectations: Expectation[], ): Span { - const { matchingSpans, lastError } = findMatchingSpansInFileDump( + const matchResult = findMatchingSpansInFileDump( spans, resourceAttributeExpectations, spanExpectations, spanAttributeExpectations, ); - return processFindSpanResult(matchingSpans, lastError); + return processFindSpanResult(matchResult); } -function processFindSpanResult(matchingSpans: Span[], lastError: Error | undefined): Span { - if (matchingSpans.length === 1) { - return matchingSpans[0]; - } else if (matchingSpans.length === 0) { - if (lastError) { - throw new Error(`No matching span found. Most recent failing expectation: ${lastError}`); +function processFindSpanResult(matchResult: MatchingSpansResult): Span { + if (matchResult.matchingSpans) { + const matchingSpans = matchResult.matchingSpans; + if (matchingSpans.length === 1) { + return matchingSpans[0]; + } else if (matchingSpans.length > 1) { + throw new Error( + `Expected exactly one matching span, found ${matchingSpans.length}.\nMatches:\n${JSON.stringify(matchingSpans, null, 2)}`, + ); } else { - throw new Error('No matching span found.'); + throw new Error('Unexpected error while processing matching spans.'); } - } else if (matchingSpans.length > 1) { + } else if (matchResult.bestCandidate) { + const bestCandidate = matchResult.bestCandidate; throw new Error( - `Expected exactly one matching span, found ${matchingSpans.length}.\nMatches:\n${JSON.stringify(matchingSpans, null, 2)} `, + `No matching span has been found. The best candidate passed ${bestCandidate.passedChecks} and failed check ${bestCandidate.passedChecks + 1} with error ${bestCandidate.error}. This is the best candidate:\n${JSON.stringify(bestCandidate.spanLike, null, 2)}`, ); } else { - throw new Error('Unexpected error while processing matching spans.'); + throw new Error('No matching span has been found.'); } } diff --git a/test/util/findMatchingSpans.ts b/test/util/findMatchingSpans.ts index 33d39e1..43e7b9f 100644 --- a/test/util/findMatchingSpans.ts +++ b/test/util/findMatchingSpans.ts @@ -10,10 +10,16 @@ const { fail } = expect; export type Expectation = (span: T) => void; -export interface MatchingSpansResult { - matchingSpans: Span[]; - lastError?: Error; -} +export type Candidate = { + spanLike?: any; + passedChecks: number; + error?: any; +}; + +export type MatchingSpansResult = { + matchingSpans?: Span[]; + bestCandidate?: Candidate; +}; export function findMatchingSpans( traceDataItems: ExportTraceServiceRequest[], @@ -24,10 +30,12 @@ export function findMatchingSpans( fail('No trace data has been provided.'); } const matchingSpans: Span[] = []; - - let lastError = null; + let bestCandidate: Candidate = { + passedChecks: 0, + }; traceDataItems.forEach(traceDataItem => { traceDataItem.resource_spans.forEach(resourceSpan => { + let passedResourceChecks = 0; if (resourceExpectations.length > 0) { // verify that the resource attribtues match const resource = resourceSpan.resource; @@ -38,33 +46,51 @@ export function findMatchingSpans( try { for (let j = 0; j < resourceExpectations.length; j++) { resourceExpectations[j](resource); + passedResourceChecks++; } } catch (error) { - // This resource did not match, try the next resource span. - lastError = error; + // This resource did not pass all checks, try the next resource span. Memorize the resource span if it has + // been the best match so far. + if (passedResourceChecks > bestCandidate.passedChecks) { + bestCandidate = { + spanLike: resourceSpan, + passedChecks: passedResourceChecks, + error, + }; + } return; } } resourceSpan.scope_spans.forEach(scopeSpan => { scopeSpan.spans.forEach(span => { + let passedChecks = passedResourceChecks; try { for (let j = 0; j < spanExpectations.length; j++) { spanExpectations[j](span); + passedChecks++; } matchingSpans.push(span); } catch (error) { - // This span did not match, try the next span. - lastError = error; + if (passedChecks > bestCandidate.passedChecks) { + bestCandidate = { + spanLike: span, + passedChecks, + error, + }; + } + // This span did not pass all checks, try the next span. Memorize the span if it has + // been the best match so far. + return; } }); }); }); }); - if (lastError) { - return { matchingSpans, lastError }; - } else { + if (matchingSpans.length > 0) { return { matchingSpans }; + } else { + return { bestCandidate }; } } @@ -78,9 +104,12 @@ export function findMatchingSpansInFileDump( fail('No trace data has been provided.'); } const matchingSpans: Span[] = []; + let bestCandidate: Candidate = { + passedChecks: 0, + }; - let lastError = null; spans.forEach(span => { + let passedChecks = 0; if (resourceAttributeExpectations.length > 0) { // verify that the resource attribtues match const resource = span.resource; @@ -96,10 +125,17 @@ export function findMatchingSpansInFileDump( try { for (let i = 0; i < resourceAttributeExpectations.length; i++) { resourceAttributeExpectations[i](resourceAttributes); + passedChecks++; } } catch (error) { - // This resource did not match, try the next resource span. - lastError = error; + // The resource attributes of this span did not match, try the next span. + if (passedChecks > bestCandidate.passedChecks) { + bestCandidate = { + spanLike: span, + passedChecks: passedChecks, + error, + }; + } return; } } @@ -107,26 +143,40 @@ export function findMatchingSpansInFileDump( try { for (let i = 0; i < spanExpectations.length; i++) { spanExpectations[i](span); + passedChecks++; } if (spanAttributeExpectations.length > 0) { const spanAttributes = span.attributes; if (!spanAttributes) { // This span has no attributes, try the next span. + if (passedChecks > bestCandidate.passedChecks) { + bestCandidate = { + spanLike: span, + passedChecks: passedChecks, + }; + } return; } for (let i = 0; i < spanAttributeExpectations.length; i++) { spanAttributeExpectations[i](spanAttributes); + passedChecks++; } } matchingSpans.push(span); } catch (error) { // This span did not match, try the next span. - lastError = error; + if (passedChecks > bestCandidate.passedChecks) { + bestCandidate = { + spanLike: span, + passedChecks: passedChecks, + error, + }; + } } }); - if (lastError) { - return { matchingSpans, lastError }; - } else { + if (matchingSpans.length > 0) { return { matchingSpans }; + } else { + return { bestCandidate }; } }