diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 0da2ea148a3f..458d82916c3b 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -1,5 +1,11 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + defineIntegration, + getCurrentScope, + safeSetSpanJSONAttributes, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; import { captureException, generateInstrumentOnce } from '@sentry/node'; import { eventContextExtractor, markEventUnhandled } from '../utils'; import { AwsLambdaInstrumentation } from './instrumentation-aws-lambda/instrumentation'; @@ -36,12 +42,50 @@ export const instrumentAwsLambda = generateInstrumentOnce( }, ); +const AWS_LAMBDA_CONTEXT_FIELDS = [ + 'aws_request_id', + 'function_name', + 'function_version', + 'invoked_function_arn', + 'execution_duration_in_millis', + 'remaining_time_in_millis', +] as const; + +const AWS_CLOUDWATCH_CONTEXT_FIELDS = ['log_group', 'log_stream', 'url'] as const; + const _awsLambdaIntegration = ((options: AwsLambdaOptions = {}) => { return { name: 'AwsLambda', setupOnce() { instrumentAwsLambda(options); }, + processSegmentSpan(span) { + const { contexts } = getCurrentScope().getScopeData(); + + const awsLambda = contexts['aws.lambda']; + if (awsLambda) { + const attrs: Record = {}; + for (const field of AWS_LAMBDA_CONTEXT_FIELDS) { + const value = awsLambda[field]; + if (typeof value === 'string' || typeof value === 'number') { + attrs[`aws.lambda.${field}`] = value; + } + } + safeSetSpanJSONAttributes(span, attrs); + } + + const awsCloudwatch = contexts['aws.cloudwatch.logs']; + if (awsCloudwatch) { + const attrs: Record = {}; + for (const field of AWS_CLOUDWATCH_CONTEXT_FIELDS) { + const value = awsCloudwatch[field]; + if (typeof value === 'string' || typeof value === 'number') { + attrs[`aws.cloudwatch.logs.${field}`] = value; + } + } + safeSetSpanJSONAttributes(span, attrs); + } + }, }; }) satisfies IntegrationFn; diff --git a/packages/aws-serverless/test/awslambda-integration.test.ts b/packages/aws-serverless/test/awslambda-integration.test.ts new file mode 100644 index 000000000000..5e64abb626b0 --- /dev/null +++ b/packages/aws-serverless/test/awslambda-integration.test.ts @@ -0,0 +1,104 @@ +import type { StreamedSpanJSON } from '@sentry/core'; +import { describe, expect, test, vi } from 'vitest'; +import { awsLambdaIntegration } from '../src/integration/awslambda'; + +const mockGetScopeData = vi.fn(); + +vi.mock('@sentry/core', async () => { + const original = await vi.importActual('@sentry/core'); + return { + ...original, + getCurrentScope: () => ({ + getScopeData: mockGetScopeData, + }), + }; +}); + +vi.mock('@sentry/node', async () => { + const original = await vi.importActual('@sentry/node'); + return { + ...original, + generateInstrumentOnce: () => () => {}, + }; +}); + +describe('awsLambdaIntegration processSegmentSpan', () => { + function makeSpanJSON(): StreamedSpanJSON { + return { + name: 'test', + span_id: 'abc', + trace_id: 'def', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: true, + attributes: {}, + }; + } + + test('maps aws.lambda context fields to segment span attributes', () => { + mockGetScopeData.mockReturnValue({ + contexts: { + 'aws.lambda': { + aws_request_id: 'req-123', + function_name: 'my-function', + function_version: '$LATEST', + invoked_function_arn: 'arn:aws:lambda:us-east-1:123:function:my-function', + execution_duration_in_millis: 150, + remaining_time_in_millis: 2850, + 'sys.argv': ['/usr/bin/node', '--secret=abc'], + }, + }, + }); + + const integration = awsLambdaIntegration(); + const span = makeSpanJSON(); + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual( + expect.objectContaining({ + 'aws.lambda.aws_request_id': 'req-123', + 'aws.lambda.function_name': 'my-function', + 'aws.lambda.function_version': '$LATEST', + 'aws.lambda.invoked_function_arn': 'arn:aws:lambda:us-east-1:123:function:my-function', + 'aws.lambda.execution_duration_in_millis': 150, + 'aws.lambda.remaining_time_in_millis': 2850, + }), + ); + expect(span.attributes).not.toHaveProperty('aws.lambda.sys.argv'); + }); + + test('maps aws.cloudwatch.logs context fields to segment span attributes', () => { + mockGetScopeData.mockReturnValue({ + contexts: { + 'aws.cloudwatch.logs': { + log_group: '/aws/lambda/my-function', + log_stream: '2024/01/01/[$LATEST]abc123', + url: 'https://console.aws.amazon.com/cloudwatch/home', + }, + }, + }); + + const integration = awsLambdaIntegration(); + const span = makeSpanJSON(); + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual( + expect.objectContaining({ + 'aws.cloudwatch.logs.log_group': '/aws/lambda/my-function', + 'aws.cloudwatch.logs.log_stream': '2024/01/01/[$LATEST]abc123', + 'aws.cloudwatch.logs.url': 'https://console.aws.amazon.com/cloudwatch/home', + }), + ); + }); + + test('does nothing when no aws contexts are set', () => { + mockGetScopeData.mockReturnValue({ contexts: {} }); + + const integration = awsLambdaIntegration(); + const span = makeSpanJSON(); + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual({}); + }); +}); diff --git a/packages/google-cloud-serverless/src/integrations/gcp-context.ts b/packages/google-cloud-serverless/src/integrations/gcp-context.ts new file mode 100644 index 000000000000..54a2f396a4e4 --- /dev/null +++ b/packages/google-cloud-serverless/src/integrations/gcp-context.ts @@ -0,0 +1,37 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, getCurrentScope, safeSetSpanJSONAttributes } from '@sentry/core'; + +const GCP_CONTEXT_ATTRIBUTE_MAP: Record = { + type: 'gcp.function.context.type', + source: 'gcp.function.context.source', + id: 'gcp.function.context.id', + specversion: 'gcp.function.context.specversion', + time: 'gcp.function.context.time', + eventId: 'gcp.function.context.event_id', + timestamp: 'gcp.function.context.timestamp', + eventType: 'gcp.function.context.event_type', + resource: 'gcp.function.context.resource', +}; + +const _gcpContextIntegration = (() => { + return { + name: 'GcpContext', + processSegmentSpan(span) { + const gcpContext = getCurrentScope().getScopeData().contexts['gcp.function.context']; + if (!gcpContext) { + return; + } + + const attrs: Record = {}; + for (const [field, attrName] of Object.entries(GCP_CONTEXT_ATTRIBUTE_MAP)) { + const value = gcpContext[field]; + if (typeof value === 'string' || typeof value === 'number') { + attrs[attrName] = value; + } + } + safeSetSpanJSONAttributes(span, attrs); + }, + }; +}) satisfies IntegrationFn; + +export const gcpContextIntegration = defineIntegration(_gcpContextIntegration); diff --git a/packages/google-cloud-serverless/src/sdk.ts b/packages/google-cloud-serverless/src/sdk.ts index 6eb80aed2f64..6ef39f444f05 100644 --- a/packages/google-cloud-serverless/src/sdk.ts +++ b/packages/google-cloud-serverless/src/sdk.ts @@ -3,6 +3,7 @@ import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, init as initNode } from '@sentry/node'; import { isCjs } from '@sentry/node-core'; +import { gcpContextIntegration } from './integrations/gcp-context'; import { googleCloudGrpcIntegration } from './integrations/google-cloud-grpc'; import { googleCloudHttpIntegration } from './integrations/google-cloud-http'; @@ -17,7 +18,7 @@ function getCjsOnlyIntegrations(): Integration[] { /** Get the default integrations for the GCP SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { - return [...getDefaultIntegrationsWithoutPerformance(), ...getCjsOnlyIntegrations()]; + return [...getDefaultIntegrationsWithoutPerformance(), gcpContextIntegration(), ...getCjsOnlyIntegrations()]; } /** diff --git a/packages/google-cloud-serverless/test/integrations/gcp-context.test.ts b/packages/google-cloud-serverless/test/integrations/gcp-context.test.ts new file mode 100644 index 000000000000..6b2b4e9c2ded --- /dev/null +++ b/packages/google-cloud-serverless/test/integrations/gcp-context.test.ts @@ -0,0 +1,118 @@ +import type { StreamedSpanJSON } from '@sentry/core'; +import { describe, expect, test, vi } from 'vitest'; +import { gcpContextIntegration } from '../../src/integrations/gcp-context'; + +const mockGetScopeData = vi.fn(); + +vi.mock('@sentry/core', async () => { + const original = await vi.importActual('@sentry/core'); + return { + ...original, + getCurrentScope: () => ({ + getScopeData: mockGetScopeData, + }), + }; +}); + +describe('gcpContextIntegration', () => { + function makeSpanJSON(): StreamedSpanJSON { + return { + name: 'test', + span_id: 'abc', + trace_id: 'def', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: true, + attributes: {}, + }; + } + + test('maps CloudEvents context fields to segment span attributes', () => { + mockGetScopeData.mockReturnValue({ + contexts: { + 'gcp.function.context': { + type: 'google.cloud.pubsub.topic.v1.messagePublished', + source: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + id: 'evt-123', + specversion: '1.0', + time: '2024-01-01T00:00:00Z', + }, + }, + }); + + const integration = gcpContextIntegration(); + const span = makeSpanJSON(); + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual( + expect.objectContaining({ + 'gcp.function.context.type': 'google.cloud.pubsub.topic.v1.messagePublished', + 'gcp.function.context.source': '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + 'gcp.function.context.id': 'evt-123', + 'gcp.function.context.specversion': '1.0', + 'gcp.function.context.time': '2024-01-01T00:00:00Z', + }), + ); + }); + + test('maps legacy CloudFunctions fields with snake_case attribute names', () => { + mockGetScopeData.mockReturnValue({ + contexts: { + 'gcp.function.context': { + eventId: 'evt-456', + timestamp: '2024-01-01T00:00:00Z', + eventType: 'providers/cloud.pubsub/eventTypes/topic.publish', + resource: 'projects/my-project/topics/my-topic', + }, + }, + }); + + const integration = gcpContextIntegration(); + const span = makeSpanJSON(); + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual( + expect.objectContaining({ + 'gcp.function.context.event_id': 'evt-456', + 'gcp.function.context.timestamp': '2024-01-01T00:00:00Z', + 'gcp.function.context.event_type': 'providers/cloud.pubsub/eventTypes/topic.publish', + 'gcp.function.context.resource': 'projects/my-project/topics/my-topic', + }), + ); + }); + + test('skips non-string values', () => { + mockGetScopeData.mockReturnValue({ + contexts: { + 'gcp.function.context': { + type: 'some.event', + resource: { service: 'pubsub', name: 'my-topic' }, + data: { payload: 'secret' }, + }, + }, + }); + + const integration = gcpContextIntegration(); + const span = makeSpanJSON(); + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual( + expect.objectContaining({ + 'gcp.function.context.type': 'some.event', + }), + ); + expect(span.attributes).not.toHaveProperty('gcp.function.context.resource'); + expect(span.attributes).not.toHaveProperty('gcp.function.context.data'); + }); + + test('does nothing when no gcp context is set', () => { + mockGetScopeData.mockReturnValue({ contexts: {} }); + + const integration = gcpContextIntegration(); + const span = makeSpanJSON(); + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual({}); + }); +});