diff --git a/packages/build/src/error/types.ts b/packages/build/src/error/types.ts index d4cbb31ab3..299db94f51 100644 --- a/packages/build/src/error/types.ts +++ b/packages/build/src/error/types.ts @@ -1,3 +1,5 @@ +import { Attributes } from '@opentelemetry/api' + // We override errorProps and title through getTitle and getErrorProps export type BuildError = Omit & { title: string @@ -75,15 +77,29 @@ export type BuildCommandLocation = { buildCommandOrigin: string } +export const isBuildCommandLocation = function (location?: ErrorLocation): location is BuildCommandLocation { + const buildLocation = location as BuildCommandLocation + return typeof buildLocation?.buildCommand === 'string' && typeof buildLocation?.buildCommandOrigin === 'string' +} + export type FunctionsBundlingLocation = { functionName: string functionType: string } +export const isFunctionsBundlingLocation = function (location?: ErrorLocation): location is FunctionsBundlingLocation { + const bundlingLocation = location as FunctionsBundlingLocation + return typeof bundlingLocation?.functionName === 'string' && typeof bundlingLocation?.functionType === 'string' +} + export type CoreStepLocation = { coreStepName: string } +export const isCoreStepLocation = function (location?: ErrorLocation): location is CoreStepLocation { + return typeof (location as CoreStepLocation)?.coreStepName === 'string' +} + export type PluginLocation = { event: string packageName: string @@ -92,11 +108,24 @@ export type PluginLocation = { input?: string } +export const isPluginLocation = function (location?: ErrorLocation): location is PluginLocation { + const pluginLocation = location as PluginLocation + return ( + typeof pluginLocation?.event === 'string' && + typeof pluginLocation?.packageName === 'string' && + typeof pluginLocation?.loadedFrom === 'string' + ) +} + export type APILocation = { endpoint: string parameters?: any } +export const isAPILocation = function (location?: ErrorLocation): location is APILocation { + return typeof (location as APILocation)?.endpoint === 'string' +} + export type ErrorLocation = | BuildCommandLocation | FunctionsBundlingLocation @@ -104,6 +133,60 @@ export type ErrorLocation = | PluginLocation | APILocation +const buildErrorAttributePrefix = 'build.error' + +const errorLocationToTracingAttributes = function (location: ErrorLocation): Attributes { + const locationAttributePrefix = `${buildErrorAttributePrefix}.location` + if (isBuildCommandLocation(location)) { + return { + [`${locationAttributePrefix}.command`]: location.buildCommand, + [`${locationAttributePrefix}.command_origin`]: location.buildCommandOrigin, + } + } + if (isPluginLocation(location)) { + return { + [`${locationAttributePrefix}.plugin.event`]: location.event, + [`${locationAttributePrefix}.plugin.package_name`]: location.packageName, + [`${locationAttributePrefix}.plugin.loaded_from`]: location.loadedFrom, + [`${locationAttributePrefix}.plugin.origin`]: location.origin, + } + } + if (isFunctionsBundlingLocation(location)) { + return { + [`${locationAttributePrefix}.function.type`]: location.functionType, + [`${locationAttributePrefix}.function.name`]: location.functionName, + } + } + + if (isCoreStepLocation(location)) { + return { + [`${locationAttributePrefix}.core_step.name`]: location.coreStepName, + } + } + + if (isAPILocation(location)) { + return { + [`${locationAttributePrefix}.api.endpoint`]: location.endpoint, + } + } + return {} +} + +/** + * Given a BuildError, extract the relevant trace attributes to add to the on-going Span + */ +export const buildErrorToTracingAttributes = function (error: BuildError | BasicErrorInfo): Attributes { + const attributes = {} + // Check we're not adding undefined values + if (error?.severity) attributes[`${buildErrorAttributePrefix}.severity`] = error.severity + if (error?.type) attributes[`${buildErrorAttributePrefix}.type`] = error.type + if (error?.locationType) attributes[`${buildErrorAttributePrefix}.location.type`] = error.locationType + return { + ...attributes, + ...errorLocationToTracingAttributes(error.errorInfo?.location), + } +} + /** * Retrieve error-type specific information */ diff --git a/packages/build/src/tracing/main.ts b/packages/build/src/tracing/main.ts index 4b47aff6e6..8a039ed4c7 100644 --- a/packages/build/src/tracing/main.ts +++ b/packages/build/src/tracing/main.ts @@ -8,6 +8,7 @@ import { NodeSDK } from '@opentelemetry/sdk-node' import type { TracingOptions } from '../core/types.js' import { isBuildError } from '../error/info.js' import { parseErrorInfo } from '../error/parse/parse.js' +import { buildErrorToTracingAttributes } from '../error/types.js' import { ROOT_PACKAGE_JSON } from '../utils/json.js' let sdk: NodeSDK | undefined @@ -94,9 +95,9 @@ export const addErrorToActiveSpan = function (error: Error) { const span = trace.getActiveSpan() if (!span) return if (isBuildError(error)) { - const { severity, type } = parseErrorInfo(error) - if (severity == 'none') return - span.setAttributes({ severity, type }) + const buildError = parseErrorInfo(error) + if (buildError.severity == 'none') return + span.setAttributes(buildErrorToTracingAttributes(buildError)) } span.recordException(error) diff --git a/packages/build/tests/error/tests.js b/packages/build/tests/error/tests.js index 576bb26e20..50d5723aa4 100644 --- a/packages/build/tests/error/tests.js +++ b/packages/build/tests/error/tests.js @@ -1,6 +1,8 @@ import { Fixture, normalizeOutput } from '@netlify/testing' import test from 'ava' +import { buildErrorToTracingAttributes } from '../../lib/error/types.js' + test('exception', async (t) => { const output = await new Fixture('./fixtures/exception').runWithBuild() t.snapshot(normalizeOutput(output)) @@ -110,3 +112,105 @@ test('Redact API token on errors', async (t) => { .runWithBuild() t.snapshot(normalizeOutput(output)) }) + +const testMatrixAttributeTracing = [ + { + description: 'build command error', + input: { + errorInfo: { location: { buildCommand: 'test-build', buildCommandOrigin: 'test-origin' } }, + severity: 'error', + type: 'build-cmd', + locationType: 'build-cmd-location-type', + }, + expects: { + 'build.error.severity': 'error', + 'build.error.type': 'build-cmd', + 'build.error.location.type': 'build-cmd-location-type', + 'build.error.location.command': 'test-build', + 'build.error.location.command_origin': 'test-origin', + }, + }, + { + description: 'plugin error', + input: { + errorInfo: { + location: { + event: 'test-event', + packageName: 'test-package', + loadedFrom: 'test-loaded-from', + origin: 'test-origin', + }, + }, + severity: 'error', + type: 'plugin-error', + locationType: 'plugin-error-location-type', + }, + expects: { + 'build.error.severity': 'error', + 'build.error.type': 'plugin-error', + 'build.error.location.type': 'plugin-error-location-type', + 'build.error.location.plugin.event': 'test-event', + 'build.error.location.plugin.package_name': 'test-package', + 'build.error.location.plugin.loaded_from': 'test-loaded-from', + 'build.error.location.plugin.origin': 'test-origin', + }, + }, + { + description: 'functions bundling error', + input: { + errorInfo: { location: { functionType: 'function-type', functionName: 'function-name' } }, + severity: 'error', + type: 'func-bundle', + locationType: 'func-bundle-location-type', + }, + expects: { + 'build.error.severity': 'error', + 'build.error.type': 'func-bundle', + 'build.error.location.type': 'func-bundle-location-type', + 'build.error.location.function.type': 'function-type', + 'build.error.location.function.name': 'function-name', + }, + }, + { + description: 'core step error', + input: { + errorInfo: { location: { coreStepName: 'some-name' } }, + severity: 'error', + type: 'core-step', + locationType: 'core-step-location-type', + }, + expects: { + 'build.error.severity': 'error', + 'build.error.type': 'core-step', + 'build.error.location.type': 'core-step-location-type', + 'build.error.location.core_step.name': 'some-name', + }, + }, + { + description: 'api error', + input: { + errorInfo: { location: { endpoint: 'some-endpoint' } }, + severity: 'error', + type: 'api', + locationType: 'api-location-type', + }, + expects: { + 'build.error.severity': 'error', + 'build.error.type': 'api', + 'build.error.location.type': 'api-location-type', + 'build.error.location.api.endpoint': 'some-endpoint', + }, + }, + { + description: 'nothing is added', + input: {}, + expects: {}, + }, +] + +testMatrixAttributeTracing.forEach(({ description, input, expects }) => { + test(`Tracing attributes - ${description}`, async (t) => { + const attributes = buildErrorToTracingAttributes(input) + t.deepEqual(attributes, expects) + }) +}) diff --git a/packages/build/tests/tracing/tests.js b/packages/build/tests/tracing/tests.js index 3655805897..a4c157bd0d 100644 --- a/packages/build/tests/tracing/tests.js +++ b/packages/build/tests/tracing/tests.js @@ -207,7 +207,11 @@ test('addErrorToActiveSpan - when error severity info', async (t) => { t.is(span.status.code, SpanStatusCode.ERROR) // Severities are infered from the Error Type - t.deepEqual(span.attributes, { severity: 'info', type: 'failPlugin' }) + t.deepEqual(span.attributes, { + 'build.error.location.type': 'buildFail', + 'build.error.severity': 'info', + 'build.error.type': 'failPlugin', + }) const firstEvent = span.events[0] t.deepEqual(firstEvent.name, 'exception')