diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index d5cfed7fae7d..750ee4c9b851 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -9,6 +9,7 @@ import { handleRequest } from './server/middleware'; // Hence, we export everything from the Node SDK explicitly: export { addBreadcrumb, + addAttachment, addEventProcessor, addIntegration, amqplibIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index b9ab9b013925..3349c7f19d69 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -1,6 +1,7 @@ export { addEventProcessor, addBreadcrumb, + addAttachment, addIntegration, captureException, captureEvent, diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 3f25eda87fe5..cd95924664ae 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -25,6 +25,7 @@ export type { BrowserOptions } from './client'; export { addEventProcessor, addBreadcrumb, + addAttachment, addIntegration, captureException, captureEvent, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 835105527b1e..a414eb378142 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -24,6 +24,7 @@ export type { export { addEventProcessor, addBreadcrumb, + addAttachment, addIntegration, captureException, captureEvent, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 33572c81714d..0350c575eede 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -25,6 +25,7 @@ export type { CloudflareOptions } from './client'; export { addEventProcessor, + addAttachment, addBreadcrumb, addIntegration, captureException, diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts new file mode 100644 index 000000000000..ce573760e6e4 --- /dev/null +++ b/packages/core/src/attachments.ts @@ -0,0 +1,27 @@ +import { getClient, getIsolationScope } from './currentScopes'; +import type { Attachment } from './types-hoist/attachment'; +/** + * Records a new breadcrumb which will be attached to future events. + * + * Breadcrumbs will be added to subsequent events to provide more context on + * user's actions prior to an error or crash. + */ +export function addAttachment(attachment: Attachment): void { + const client = getClient(); + const isolationScope = getIsolationScope(); + + if (!client) return; + + // const mergedAttachment = { timestamp, ...attachment }; + // const finalAttachment = beforeAttachment + // ? consoleSandbox(() => beforeAttachment(mergedAttachment, hint)) + // : mergedAttachment; + + // if (finalAttachment === null) return; + + // if (client.emit) { + // client.emit('beforeAddAttachment', finalAttachment, hint); + // } + + isolationScope.addAttachment(attachment); +} diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 875056890e0e..bc71d1e6eba9 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -1,9 +1,11 @@ import type { Client } from './client'; import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext'; import type { SentrySpan } from './tracing/sentrySpan'; +import type { Attachment } from './types-hoist/attachment'; import type { LegacyCSPReport } from './types-hoist/csp'; import type { DsnComponents } from './types-hoist/dsn'; import type { + AttachmentEnvelope, DynamicSamplingContext, EventEnvelope, EventItem, @@ -23,11 +25,13 @@ import { createEnvelope, createEventEnvelopeHeaders, createSpanEnvelopeItem, + createTraceAttachmentEnvelopeItem, getSdkMetadataForEnvelopeHeader, } from './utils/envelope'; import { uuid4 } from './utils/misc'; import { shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning, spanToJSON } from './utils/spanUtils'; +import { timestampInSeconds } from './utils/time'; /** * Apply SdkInfo (name, version, packages, integrations) to the corresponding event key. @@ -196,3 +200,44 @@ export function createRawSecurityEnvelope( return createEnvelope(envelopeHeaders, [eventItem]); } + +/** + * Create envelope from Attachment item using the trace attachment format. + * + * This creates a standalone attachment envelope with trace context according to + * the experimental trace attachment specification: + * https://develop.sentry.dev/sdk/data-model/envelope-items/#trace-attachment + * + * The attachment payload includes metadata with trace_id, attachment_id, + * timestamp, and optional attributes. + * + * @param attachment - The attachment to send + * @param dsc - Dynamic Sampling Context containing trace information + * @param dsn - DSN components for the envelope header + * @param tunnel - Tunnel URL if configured + * @param attributes - Optional arbitrary attributes for querying in EAP + * @param traceId - The trace_id to associate with this attachment + */ +export function createAttachmentEnvelope( + attachment: Attachment, + dsc: Partial | undefined, + dsn: DsnComponents | undefined, + tunnel: string | undefined, + attributes: Record | undefined, + traceId: string, +): AttachmentEnvelope { + function dscHasRequiredProps(dsc: Partial): dsc is DynamicSamplingContext { + return !!dsc.trace_id && !!dsc.public_key; + } + + const headers: AttachmentEnvelope[0] = { + sent_at: new Date().toISOString(), + ...(dsc && dscHasRequiredProps(dsc) && { trace: dsc }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }; + + const timestamp = timestampInSeconds(); + const attachmentItem = createTraceAttachmentEnvelopeItem(attachment, traceId, timestamp, attributes); + + return createEnvelope(headers, [attachmentItem]); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30e24c3b35c7..beae4fbbcf9f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,7 +9,7 @@ export type { IntegrationIndex } from './integration'; export * from './tracing'; export * from './semanticAttributes'; -export { createEventEnvelope, createSessionEnvelope, createSpanEnvelope } from './envelope'; +export { createAttachmentEnvelope, createEventEnvelope, createSessionEnvelope, createSpanEnvelope } from './envelope'; export { captureCheckIn, withMonitor, @@ -103,6 +103,7 @@ export { } from './utils/request'; export { DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; +export { addAttachment } from './attachments'; export { functionToStringIntegration } from './integrations/functiontostring'; // eslint-disable-next-line deprecation/deprecation export { inboundFiltersIntegration } from './integrations/eventFilters'; @@ -280,6 +281,7 @@ export { createEnvelope, createEventEnvelopeHeaders, createSpanEnvelopeItem, + createTraceAttachmentEnvelopeItem, envelopeContainsItemType, envelopeItemTypeToDataCategory, forEachEnvelopeItem, @@ -349,6 +351,7 @@ export type { DataCategory } from './types-hoist/datacategory'; export type { DsnComponents, DsnLike, DsnProtocol } from './types-hoist/dsn'; export type { DebugImage, DebugMeta } from './types-hoist/debugMeta'; export type { + AttachmentEnvelope, AttachmentItem, BaseEnvelopeHeaders, BaseEnvelopeItemHeaders, diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 0639cdb845f1..bb4b3db16337 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -2,7 +2,9 @@ import type { AttributeObject, RawAttribute, RawAttributes } from './attributes'; import type { Client } from './client'; import { DEBUG_BUILD } from './debug-build'; +import { createAttachmentEnvelope } from './envelope'; import { updateSession } from './session'; +import { getDynamicSamplingContextFromSpan } from './tracing/dynamicSamplingContext'; import type { Attachment } from './types-hoist/attachment'; import type { Breadcrumb } from './types-hoist/breadcrumb'; import type { Context, Contexts } from './types-hoist/context'; @@ -23,6 +25,7 @@ import { merge } from './utils/merge'; import { uuid4 } from './utils/misc'; import { generateTraceId } from './utils/propagationContext'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; +import { getActiveSpan } from './utils/spanUtils'; import { truncate } from './utils/string'; import { dateTimestampInSeconds } from './utils/time'; @@ -604,9 +607,23 @@ export class Scope { /** * Add an attachment to the scope. + * + * For the trace attachments PoC, this will immediately send the attachment + * in a standalone envelope with trace context. */ public addAttachment(attachment: Attachment): this { - this._attachments.push(attachment); + const span = getActiveSpan(); + const dsc = span ? getDynamicSamplingContextFromSpan(span) : undefined; + const traceId = span?.spanContext().traceId || dsc?.trace_id || this._propagationContext.traceId; + const dsn = this._client?.getDsn(); + const tunnel = this._client?.getOptions().tunnel; + const envelope = createAttachmentEnvelope(attachment, dsc, dsn, tunnel, undefined, traceId); + + void this._client?.sendEnvelope(envelope); + + // Still add to the scope's attachments array for backward compatibility + // this._attachments.push(attachment); + return this; } diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 272f8cde9f62..4ce42c54907f 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -76,9 +76,10 @@ type EventItemHeaders = { type AttachmentItemHeaders = { type: 'attachment'; length: number; - filename: string; + filename?: string; content_type?: string; attachment_type?: AttachmentType; + meta_length?: number; }; type UserFeedbackItemHeaders = { type: 'user_report' }; type FeedbackItemHeaders = { type: 'feedback' }; @@ -133,6 +134,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; +type AttachmentEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; type LogEnvelopeHeaders = BaseEnvelopeHeaders; type MetricEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< @@ -144,6 +146,7 @@ export type ClientReportEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; +export type AttachmentEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; export type LogEnvelope = BaseEnvelope; @@ -157,6 +160,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope + | AttachmentEnvelope | RawSecurityEnvelope | LogEnvelope | MetricEnvelope; diff --git a/packages/core/src/utils/envelope.ts b/packages/core/src/utils/envelope.ts index 8f21a00dc590..b13260ac53ec 100644 --- a/packages/core/src/utils/envelope.ts +++ b/packages/core/src/utils/envelope.ts @@ -16,6 +16,7 @@ import type { SdkInfo } from '../types-hoist/sdkinfo'; import type { SdkMetadata } from '../types-hoist/sdkmetadata'; import type { SpanJSON } from '../types-hoist/span'; import { dsnToString } from './dsn'; +import { uuid4 } from './misc'; import { normalize } from './normalize'; import { GLOBAL_OBJ } from './worldwide'; @@ -187,7 +188,7 @@ export function createSpanEnvelopeItem(spanJson: Partial): SpanItem { } /** - * Creates attachment envelope items + * Creates attachment envelope items (traditional format - for events) */ export function createAttachmentEnvelopeItem(attachment: Attachment): AttachmentItem { const buffer = typeof attachment.data === 'string' ? encodeUTF8(attachment.data) : attachment.data; @@ -204,6 +205,65 @@ export function createAttachmentEnvelopeItem(attachment: Attachment): Attachment ]; } +/** + * Creates a trace attachment envelope item with metadata prefix. + * + * This implements the experimental trace attachment format as specified at: + * https://develop.sentry.dev/sdk/data-model/envelope-items/#trace-attachment + * + * @param attachment - The attachment to create an item for + * @param traceId - The trace ID (32 character hex string) + * @param timestamp - UNIX timestamp as a float + * @param attributes - Optional arbitrary attributes for querying + */ +export function createTraceAttachmentEnvelopeItem( + attachment: Attachment, + traceId: string, + timestamp: number, + attributes?: Record, +): AttachmentItem { + const attachmentBody = typeof attachment.data === 'string' ? encodeUTF8(attachment.data) : attachment.data; + + // Generate a unique attachment ID (UUID v4 without dashes) + const attachmentId = uuid4().replace(/-/g, ''); + + // Create the metadata JSON object + const metadata: Record = { + trace_id: traceId, + attachment_id: attachmentId, + timestamp, + content_type: attachment.contentType || 'application/octet-stream', + }; + + if (attachment.filename) { + metadata.filename = attachment.filename; + } + + if (attributes) { + metadata.attributes = attributes; + } + + // Serialize metadata to JSON + const metadataJson = JSON.stringify(metadata); + const metadataBuffer = encodeUTF8(metadataJson); + + // Combine metadata and attachment body + const combinedLength = metadataBuffer.length + attachmentBody.length; + const combinedBuffer = new Uint8Array(combinedLength); + combinedBuffer.set(metadataBuffer, 0); + combinedBuffer.set(attachmentBody, metadataBuffer.length); + + return [ + { + type: 'attachment', + content_type: 'application/vnd.sentry.trace-attachment', + length: combinedLength, + meta_length: metadataBuffer.length, + }, + combinedBuffer, + ]; +} + const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { session: 'session', sessions: 'session', diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 5f987b4459aa..3c2809a1dcd6 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -21,6 +21,7 @@ export type { export type { DenoOptions } from './types'; export { + addAttachment, addEventProcessor, addBreadcrumb, captureException, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 72fd0fa3a12d..dfc114fd052b 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -1,4 +1,5 @@ export { + addAttachment, addEventProcessor, addBreadcrumb, addIntegration, diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index 8ab20e9dfd4c..3a7f8f5314df 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -58,6 +58,7 @@ export { } from '@sentry/opentelemetry'; export { + addAttachment, addBreadcrumb, isInitialized, isEnabled, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bb655b87fc42..d0d2c421008a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -58,6 +58,7 @@ export { export { addBreadcrumb, + addAttachment, isInitialized, isEnabled, getGlobalScope, diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 9b78855ae2d3..49acbd5f1454 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -46,6 +46,7 @@ export type { } from '@sentry/core'; export { + addAttachment, addEventProcessor, addBreadcrumb, addIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 181c9fd36d16..517aacc9596d 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -4,6 +4,7 @@ // We need to explicitly export @sentry/node as they end up under `default` in ESM builds // See: https://github.com/getsentry/sentry-javascript/issues/8474 export { + addAttachment, addBreadcrumb, addEventProcessor, addIntegration, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 6e2bc1cb9f61..9cdfd4b4d0cf 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -7,6 +7,7 @@ // on the top - level namespace. // Hence, we export everything from the Node SDK explicitly: export { + addAttachment, addBreadcrumb, addEventProcessor, addIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 47c4cfa7d3f8..2cbed9684898 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -5,6 +5,7 @@ // on the top - level namespace. // Hence, we export everything from the Node SDK explicitly: export { + addAttachment, addBreadcrumb, addEventProcessor, addIntegration, diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index 8e4645741456..4f8fcbfc8964 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -16,6 +16,7 @@ export { wrapServerRouteWithSentry } from '../server-common/serverRoute'; // Re-export some functions from Cloudflare SDK export { + addAttachment, addBreadcrumb, addEventProcessor, addIntegration, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 8ece38279732..7312e6d72839 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -22,6 +22,7 @@ export type { export type { VercelEdgeOptions } from './types'; export { + addAttachment, addEventProcessor, addBreadcrumb, addIntegration,