diff --git a/dev-packages/e2e-tests/test-applications/node-effect/.gitignore b/dev-packages/e2e-tests/test-applications/node-effect/.gitignore new file mode 100644 index 000000000000..de4d1f007dd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/dev-packages/e2e-tests/test-applications/node-effect/.npmrc b/dev-packages/e2e-tests/test-applications/node-effect/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-effect/package.json b/dev-packages/e2e-tests/test-applications/node-effect/package.json new file mode 100644 index 000000000000..ee9b5d0dba25 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/package.json @@ -0,0 +1,30 @@ +{ + "name": "node-effect-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node --import=./src/instrument.js dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@effect/platform": "^0.94.0", + "@effect/platform-node": "^0.104.0", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@types/node": "^18.19.1", + "effect": "^3.0.0", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-effect/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-effect/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-effect/src/Api.ts b/dev-packages/e2e-tests/test-applications/node-effect/src/Api.ts new file mode 100644 index 000000000000..606159592e09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/src/Api.ts @@ -0,0 +1,4 @@ +import { HttpApi } from '@effect/platform'; +import { TestApi } from './Test/Api.js'; + +export class Api extends HttpApi.make('api').add(TestApi) {} diff --git a/dev-packages/e2e-tests/test-applications/node-effect/src/Http.ts b/dev-packages/e2e-tests/test-applications/node-effect/src/Http.ts new file mode 100644 index 000000000000..9b09882a36f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/src/Http.ts @@ -0,0 +1,14 @@ +import { HttpApiBuilder, HttpMiddleware, HttpServer } from '@effect/platform'; +import { NodeHttpServer } from '@effect/platform-node'; +import { Layer } from 'effect'; +import { createServer } from 'http'; +import { Api } from './Api.js'; +import { HttpTestLive } from './Test/Http.js'; + +const ApiLive = Layer.provide(HttpApiBuilder.api(Api), [HttpTestLive]); + +export const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe( + Layer.provide(ApiLive), + HttpServer.withLogAddress, + Layer.provide(NodeHttpServer.layer(createServer, { port: 3030 })), +); diff --git a/dev-packages/e2e-tests/test-applications/node-effect/src/Test/Api.ts b/dev-packages/e2e-tests/test-applications/node-effect/src/Test/Api.ts new file mode 100644 index 000000000000..02f3cca943c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/src/Test/Api.ts @@ -0,0 +1,31 @@ +import { HttpApiEndpoint, HttpApiGroup } from '@effect/platform'; +import { Schema } from 'effect'; + +export const TestResult = Schema.Struct({ + status: Schema.String, +}); + +export const NestedResult = Schema.Struct({ + result: Schema.String, +}); + +export const ErrorResult = Schema.Struct({ + error: Schema.String, +}); + +export const LogResult = Schema.Struct({ + logged: Schema.Boolean, +}); + +export const MetricResult = Schema.Struct({ + incremented: Schema.Number, +}); + +export class TestApi extends HttpApiGroup.make('test') + .add(HttpApiEndpoint.get('success', '/test-success').addSuccess(TestResult)) + .add(HttpApiEndpoint.get('effectSpan', '/test-effect-span').addSuccess(TestResult)) + .add(HttpApiEndpoint.get('nestedSpans', '/test-nested-spans').addSuccess(NestedResult)) + .add(HttpApiEndpoint.get('effectError', '/test-effect-error').addSuccess(ErrorResult)) + .add(HttpApiEndpoint.get('effectLog', '/test-effect-log').addSuccess(LogResult)) + .add(HttpApiEndpoint.get('effectMetric', '/test-effect-metric').addSuccess(MetricResult)) + .add(HttpApiEndpoint.get('effectWithHttp', '/test-effect-with-http').addSuccess(TestResult)) {} diff --git a/dev-packages/e2e-tests/test-applications/node-effect/src/Test/Http.ts b/dev-packages/e2e-tests/test-applications/node-effect/src/Test/Http.ts new file mode 100644 index 000000000000..84c5a39ddadb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/src/Test/Http.ts @@ -0,0 +1,70 @@ +import { HttpApiBuilder } from '@effect/platform'; +import { Effect, Metric } from 'effect'; +import { Api } from '../Api.js'; + +const requestCounter = Metric.counter('http_requests_total').pipe(Metric.withConstantInput(1)); + +export const HttpTestLive = HttpApiBuilder.group(Api, 'test', handlers => + Effect.gen(function* () { + return handlers + .handle('success', () => Effect.succeed({ status: 'ok' })) + + .handle('effectSpan', () => + Effect.gen(function* () { + yield* Effect.log('Starting effect span test'); + yield* Effect.sleep('10 millis'); + return { status: 'ok' }; + }).pipe(Effect.withSpan('test-effect-span')), + ) + + .handle('nestedSpans', () => { + const innerEffect = Effect.gen(function* () { + yield* Effect.sleep('5 millis'); + return 'inner-result'; + }).pipe(Effect.withSpan('inner-span')); + + return Effect.gen(function* () { + const result = yield* innerEffect; + yield* Effect.sleep('5 millis'); + return { result }; + }).pipe(Effect.withSpan('outer-span')); + }) + + .handle('effectError', () => + Effect.gen(function* () { + yield* Effect.fail(new Error('Effect error')); + return { error: '' }; + }).pipe( + Effect.withSpan('error-span'), + Effect.catchAll(error => Effect.succeed({ error: error instanceof Error ? error.message : String(error) })), + ), + ) + + .handle('effectLog', () => + Effect.gen(function* () { + yield* Effect.log('Test info log message'); + yield* Effect.logDebug('Test debug log message'); + yield* Effect.logWarning('Test warning log message'); + yield* Effect.logError('Test error log message'); + return { logged: true }; + }), + ) + + .handle('effectMetric', () => + Effect.gen(function* () { + yield* Metric.increment(requestCounter); + yield* Metric.increment(requestCounter); + yield* Metric.increment(requestCounter); + return { incremented: 3 }; + }), + ) + + .handle('effectWithHttp', () => + Effect.gen(function* () { + yield* Effect.log('Processing request'); + yield* Effect.sleep('10 millis'); + return { status: 'ok' }; + }).pipe(Effect.withSpan('process-request', { kind: 'server' })), + ); + }), +); diff --git a/dev-packages/e2e-tests/test-applications/node-effect/src/app.ts b/dev-packages/e2e-tests/test-applications/node-effect/src/app.ts new file mode 100644 index 000000000000..45260ca068cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/src/app.ts @@ -0,0 +1,8 @@ +import { NodeRuntime } from '@effect/platform-node'; +import { Layer } from 'effect'; +import { effectLayer } from '@sentry/core/effect'; +import { HttpLive } from './Http.js'; + +const MainLive = HttpLive.pipe(Layer.provide(effectLayer)); + +MainLive.pipe(Layer.launch, NodeRuntime.runMain); diff --git a/dev-packages/e2e-tests/test-applications/node-effect/src/instrument.js b/dev-packages/e2e-tests/test-applications/node-effect/src/instrument.js new file mode 100644 index 000000000000..9546b5e840b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/src/instrument.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1, + enableLogs: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-effect/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-effect/start-event-proxy.mjs new file mode 100644 index 000000000000..093e9aa87f5f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-effect', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-effect/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/node-effect/tests/logs.test.ts new file mode 100644 index 000000000000..1a2d8f17dfaa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/tests/logs.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLog, SerializedLogContainer } from '@sentry/core'; + +async function collectLogItems(baseURL: string, endpoint: string): Promise { + const items: SerializedLog[] = []; + + const logEnvelopePromise = waitForEnvelopeItem('node-effect', envelope => { + if (envelope[0].type !== 'log') return false; + const container = envelope[1] as SerializedLogContainer; + items.push(...container.items); + return container.items.some(item => item.body?.toString().includes('Test')); + }); + + await fetch(`${baseURL}${endpoint}`); + await logEnvelopePromise; + + return items; +} + +test('Captures Effect logs with correct severity levels', async ({ baseURL }) => { + const items = await collectLogItems(baseURL!, '/test-effect-log'); + + expect(items.length).toBeGreaterThan(0); + + const infoLog = items.find(item => item.body?.toString().includes('Test info log message')); + expect(infoLog).toBeDefined(); + expect(infoLog?.severity_number).toBe(9); + + const warningLog = items.find(item => item.body?.toString().includes('Test warning log message')); + expect(warningLog).toBeDefined(); + expect(warningLog?.severity_number).toBe(13); + + const errorLog = items.find(item => item.body?.toString().includes('Test error log message')); + expect(errorLog).toBeDefined(); + expect(errorLog?.severity_number).toBe(17); + + for (const item of items) { + expect(item.body).toBeDefined(); + expect(item.severity_number).toBeDefined(); + expect(item.timestamp).toBeDefined(); + } +}); + +test('Effect logs are associated with trace context when inside a span', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('node-effect', envelope => { + if (envelope[0].type !== 'log') return false; + const container = envelope[1] as SerializedLogContainer; + return container.items.some(item => item.body?.toString().includes('Starting effect span test')); + }); + + await fetch(`${baseURL}/test-effect-span`); + + const logEnvelope = await logEnvelopePromise; + const container = logEnvelope[1] as SerializedLogContainer; + const spanLog = container.items.find(item => item.body?.toString().includes('Starting effect span test')); + + expect(spanLog).toBeDefined(); + expect(spanLog?.trace_id).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-effect/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/node-effect/tests/tracing.test.ts new file mode 100644 index 000000000000..1547b9215253 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/tests/tracing.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an Effect span as a Sentry transaction', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-effect', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-effect-span' + ); + }); + + await fetch(`${baseURL}/test-effect-span`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-effect-span'); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'test-effect-span', + op: 'internal', + origin: 'manual', + status: 'ok', + }), + ); +}); + +test('Captures nested Effect spans correctly', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-effect', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-nested-spans' + ); + }); + + await fetch(`${baseURL}/test-nested-spans`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-nested-spans'); + + const spans = transactionEvent.spans || []; + + const outerSpan = spans.find(span => span.description === 'outer-span'); + const innerSpan = spans.find(span => span.description === 'inner-span'); + + expect(outerSpan).toBeDefined(); + expect(innerSpan).toBeDefined(); + + expect(outerSpan).toMatchObject({ + description: 'outer-span', + op: 'internal', + status: 'ok', + }); + + expect(innerSpan).toMatchObject({ + description: 'inner-span', + op: 'internal', + status: 'ok', + }); + + expect(innerSpan?.parent_span_id).toEqual(outerSpan?.span_id); +}); + +test('Captures Effect span error status correctly', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-effect', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-effect-error' + ); + }); + + await fetch(`${baseURL}/test-effect-error`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + const errorSpan = spans.find(span => span.description === 'error-span'); + + expect(errorSpan).toBeDefined(); + expect(errorSpan).toMatchObject({ + description: 'error-span', + op: 'internal', + status: 'internal_error', + }); +}); + +test('Effect server spans attach to existing HTTP server span', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-effect', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-effect-with-http' + ); + }); + + await fetch(`${baseURL}/test-effect-with-http`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + + const spans = transactionEvent.spans || []; + + const processSpan = spans.find(span => span.description === 'process-request'); + expect(processSpan).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-effect/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-effect/tsconfig.json new file mode 100644 index 000000000000..982edb837c33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-effect/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 2bb2209bbe92..5ac24aafcf73 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,12 +26,25 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./effect": { + "import": { + "types": "./build/types/integrations/effect/index.d.ts", + "default": "./build/esm/integrations/effect/index.js" + }, + "require": { + "types": "./build/types/integrations/effect/index.d.ts", + "default": "./build/cjs/integrations/effect/index.js" + } } }, "typesVersions": { "<5.0": { "build/types/index.d.ts": [ "build/types-ts3.8/index.d.ts" + ], + "build/types/integrations/effect/index.d.ts": [ + "build/types-ts3.8/integrations/effect/index.d.ts" ] } }, @@ -62,7 +75,16 @@ "extends": "../../package.json" }, "sideEffects": false, + "peerDependencies": { + "effect": "^3.0.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + }, "devDependencies": { + "effect": "^3.19.19", "zod": "^3.24.1" } } diff --git a/packages/core/rollup.npm.config.mjs b/packages/core/rollup.npm.config.mjs index cc3ad4064820..7cfc58c8323c 100644 --- a/packages/core/rollup.npm.config.mjs +++ b/packages/core/rollup.npm.config.mjs @@ -14,26 +14,40 @@ if (!packageJson.version) { const packageVersion = packageJson.version; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - packageSpecificConfig: { - output: { - // set exports to 'named' or 'auto' so that rollup doesn't warn - exports: 'named', - // set preserveModules to true because we don't want to bundle everything into one file. - preserveModules: - process.env.SENTRY_BUILD_PRESERVE_MODULES === undefined - ? true - : Boolean(process.env.SENTRY_BUILD_PRESERVE_MODULES), - }, - plugins: [ - replace({ - preventAssignment: true, - values: { - __SENTRY_SDK_VERSION__: JSON.stringify(packageVersion), - }, - }), - ], +const baseConfig = makeBaseNPMConfig({ + packageSpecificConfig: { + output: { + // set exports to 'named' or 'auto' so that rollup doesn't warn + exports: 'named', + // set preserveModules to true because we don't want to bundle everything into one file. + preserveModules: + process.env.SENTRY_BUILD_PRESERVE_MODULES === undefined + ? true + : Boolean(process.env.SENTRY_BUILD_PRESERVE_MODULES), }, - }), -); + plugins: [ + replace({ + preventAssignment: true, + values: { + __SENTRY_SDK_VERSION__: JSON.stringify(packageVersion), + }, + }), + ], + }, +}); + +// Extend external to handle effect/* subpath imports +const originalExternal = baseConfig.external || []; +baseConfig.external = id => { + // Match effect and all effect/* subpaths + if (id === 'effect' || id.startsWith('effect/')) { + return true; + } + // Fall back to original external array check + if (Array.isArray(originalExternal)) { + return originalExternal.includes(id); + } + return false; +}; + +export default makeNPMConfigVariants(baseConfig); diff --git a/packages/core/src/integrations/effect/index.ts b/packages/core/src/integrations/effect/index.ts new file mode 100644 index 000000000000..ab3758393784 --- /dev/null +++ b/packages/core/src/integrations/effect/index.ts @@ -0,0 +1,58 @@ +import type * as Layer from 'effect/Layer'; +import { empty as emptyLayer, provideMerge, suspend as suspendLayer } from 'effect/Layer'; +import { defaultLogger, replace as replaceLogger } from 'effect/Logger'; +import { getClient } from '../../currentScopes'; +import { SentryEffectLogger } from './logger'; +import { SentryEffectMetricsLayer } from './metrics'; +import { SentryEffectTracerLayer } from './tracer'; + +function makeEffectSentryLayerInternal(): Layer.Layer { + const currentClient = getClient(); + + if (!currentClient) { + return emptyLayer; + } + + const options = currentClient.getOptions() as { enableLogs?: boolean; enableMetrics?: boolean }; + const { enableLogs = false, enableMetrics = false } = options ?? {}; + + let layer: Layer.Layer = SentryEffectTracerLayer; + + if (enableLogs) { + const effectLogger = replaceLogger(defaultLogger, SentryEffectLogger); + layer = layer.pipe(provideMerge(effectLogger)); + } + + if (enableMetrics) { + layer = layer.pipe(provideMerge(SentryEffectMetricsLayer)); + } + + return layer; +} + +/** + * Effect Layer that integrates Sentry tracing, logging, and metrics. + * + * This layer provides Effect applications with Sentry instrumentation: + * - Traces Effect spans as Sentry spans + * - Forwards Effect logs to Sentry (when `enableLogs` is set in Sentry options) + * - Sends Effect metrics to Sentry (when `enableMetrics` is set in Sentry options) + * + * @example + * ```typescript + * import * as Sentry from '@sentry/node'; + * import { Effect } from 'effect'; + * + * Sentry.init({ + * dsn: 'your-dsn', + * enableLogs: true, + * enableMetrics: true, + * }); + * + * // Use in your Effect program + * Effect.runPromise( + * myProgram.pipe(Effect.provide(Sentry.effectLayer)) + * ); + * ``` + */ +export const effectLayer: Layer.Layer = suspendLayer(() => makeEffectSentryLayerInternal()); diff --git a/packages/core/src/integrations/effect/logger.ts b/packages/core/src/integrations/effect/logger.ts new file mode 100644 index 000000000000..29dbd77648ea --- /dev/null +++ b/packages/core/src/integrations/effect/logger.ts @@ -0,0 +1,22 @@ +import * as Logger from 'effect/Logger'; +import * as LogLevel from 'effect/LogLevel'; +import * as sentryLogger from '../../logs/public-api'; + +/** + * Effect Logger that sends logs to Sentry. + */ +export const SentryEffectLogger = Logger.make(({ logLevel, message }) => { + const msg = typeof message === 'string' ? message : JSON.stringify(message); + + if (LogLevel.greaterThanEqual(logLevel, LogLevel.Error)) { + sentryLogger.error(msg); + } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Warning)) { + sentryLogger.warn(msg); + } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Info)) { + sentryLogger.info(msg); + } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Debug)) { + sentryLogger.debug(msg); + } else { + sentryLogger.trace(msg); + } +}); diff --git a/packages/core/src/integrations/effect/metrics.ts b/packages/core/src/integrations/effect/metrics.ts new file mode 100644 index 000000000000..83a21fa6c4de --- /dev/null +++ b/packages/core/src/integrations/effect/metrics.ts @@ -0,0 +1,102 @@ +import * as Effect from 'effect/Effect'; +import type * as Layer from 'effect/Layer'; +import { scopedDiscard } from 'effect/Layer'; +import * as Metric from 'effect/Metric'; +import * as MetricKeyType from 'effect/MetricKeyType'; +import type * as MetricPair from 'effect/MetricPair'; +import * as MetricState from 'effect/MetricState'; +import * as Schedule from 'effect/Schedule'; +import * as sentryMetrics from '../../metrics/public-api'; + +type MetricAttributes = Record; + +function labelsToAttributes(labels: ReadonlyArray<{ key: string; value: string }>): MetricAttributes { + return labels.reduce((acc, label) => ({ ...acc, [label.key]: label.value }), {}); +} + +function sendMetricToSentry(pair: MetricPair.MetricPair.Untyped): void { + const { metricKey, metricState } = pair; + const name = metricKey.name; + const attributes = labelsToAttributes(metricKey.tags); + + if (MetricState.isCounterState(metricState)) { + const value = typeof metricState.count === 'bigint' ? Number(metricState.count) : metricState.count; + sentryMetrics.count(name, value, { attributes }); + } else if (MetricState.isGaugeState(metricState)) { + const value = typeof metricState.value === 'bigint' ? Number(metricState.value) : metricState.value; + sentryMetrics.gauge(name, value, { attributes }); + } else if (MetricState.isHistogramState(metricState)) { + sentryMetrics.distribution(`${name}.sum`, metricState.sum, { attributes }); + sentryMetrics.gauge(`${name}.count`, metricState.count, { attributes }); + sentryMetrics.gauge(`${name}.min`, metricState.min, { attributes }); + sentryMetrics.gauge(`${name}.max`, metricState.max, { attributes }); + } else if (MetricState.isSummaryState(metricState)) { + sentryMetrics.distribution(`${name}.sum`, metricState.sum, { attributes }); + sentryMetrics.gauge(`${name}.count`, metricState.count, { attributes }); + sentryMetrics.gauge(`${name}.min`, metricState.min, { attributes }); + sentryMetrics.gauge(`${name}.max`, metricState.max, { attributes }); + } else if (MetricState.isFrequencyState(metricState)) { + for (const [word, count] of metricState.occurrences) { + sentryMetrics.count(name, count, { + attributes: { ...attributes, word }, + }); + } + } +} + +const previousCounterValues = new Map(); + +function getMetricId(pair: MetricPair.MetricPair.Untyped): string { + const tags = pair.metricKey.tags.map(t => `${t.key}=${t.value}`).join(','); + return `${pair.metricKey.name}:${tags}`; +} + +function sendDeltaMetricToSentry(pair: MetricPair.MetricPair.Untyped): void { + const { metricKey, metricState } = pair; + const name = metricKey.name; + const attributes = labelsToAttributes(metricKey.tags); + const metricId = getMetricId(pair); + + if (MetricState.isCounterState(metricState)) { + const currentValue = typeof metricState.count === 'bigint' ? Number(metricState.count) : metricState.count; + + const previousValue = previousCounterValues.get(metricId) ?? 0; + const delta = currentValue - previousValue; + + if (delta > 0) { + sentryMetrics.count(name, delta, { attributes }); + } + + previousCounterValues.set(metricId, currentValue); + } else { + sendMetricToSentry(pair); + } +} + +function flushMetricsToSentry(): void { + const snapshot = Metric.unsafeSnapshot(); + + snapshot.forEach(pair => { + if (MetricKeyType.isCounterKey(pair.metricKey.keyType)) { + sendDeltaMetricToSentry(pair); + } else { + sendMetricToSentry(pair); + } + }); +} + +const metricsReporterEffect = Effect.gen(function* () { + const schedule = Schedule.spaced('10 seconds'); + + yield* Effect.repeat( + Effect.sync(() => flushMetricsToSentry()), + schedule, + ); +}).pipe(Effect.interruptible); + +/** + * Effect Layer that periodically flushes metrics to Sentry. + */ +export const SentryEffectMetricsLayer: Layer.Layer = scopedDiscard( + Effect.forkScoped(metricsReporterEffect), +); diff --git a/packages/core/src/integrations/effect/tracer.ts b/packages/core/src/integrations/effect/tracer.ts new file mode 100644 index 000000000000..445fbf1aee90 --- /dev/null +++ b/packages/core/src/integrations/effect/tracer.ts @@ -0,0 +1,172 @@ +import type * as Context from 'effect/Context'; +import * as Exit from 'effect/Exit'; +import type * as Layer from 'effect/Layer'; +import { setTracer } from 'effect/Layer'; +import * as Option from 'effect/Option'; +import * as EffectTracer from 'effect/Tracer'; +import { startInactiveSpan, withActiveSpan } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; +import { getActiveSpan, spanToJSON } from '../../utils/spanUtils'; + +const KIND_MAP: Record = { + internal: 'internal', + client: 'client', + server: 'server', + producer: 'producer', + consumer: 'consumer', +}; + +type HrTime = [number, number]; + +const SENTRY_SPAN_SYMBOL = Symbol.for('@sentry/effect.SentrySpan'); + +function nanosToHrTime(nanos: bigint): HrTime { + const seconds = Number(nanos / BigInt(1_000_000_000)); + const remainingNanos = Number(nanos % BigInt(1_000_000_000)); + return [seconds, remainingNanos]; +} + +interface SentrySpanLike extends EffectTracer.Span { + readonly [SENTRY_SPAN_SYMBOL]: true; + readonly sentrySpan: Span; +} + +function isSentrySpan(span: EffectTracer.AnySpan): span is SentrySpanLike { + return SENTRY_SPAN_SYMBOL in span; +} + +function isHttpServerSpan(span: Span): boolean { + const op = spanToJSON(span).op; + return op === 'http.server'; +} + +class SentrySpanWrapper implements SentrySpanLike { + public readonly [SENTRY_SPAN_SYMBOL]: true; + public readonly _tag: 'Span'; + public readonly spanId: string; + public readonly traceId: string; + public readonly attributes: Map; + public readonly sampled: boolean; + public readonly parent: Option.Option; + public readonly links: Array; + public status: EffectTracer.SpanStatus; + public readonly ownsSpan: boolean; + public readonly sentrySpan: Span; + + public constructor( + public readonly name: string, + parent: Option.Option, + public readonly context: Context.Context, + links: ReadonlyArray, + startTime: bigint, + public readonly kind: EffectTracer.SpanKind, + existingSpan: Span, + ownsSpan: boolean, + ) { + this[SENTRY_SPAN_SYMBOL] = true as const; + this._tag = 'Span' as const; + this.attributes = new Map(); + this.parent = parent; + this.links = links.slice(); + this.sentrySpan = existingSpan; + this.ownsSpan = ownsSpan; + + const spanContext = this.sentrySpan.spanContext(); + this.spanId = spanContext.spanId; + this.traceId = spanContext.traceId; + this.sampled = this.sentrySpan.isRecording(); + this.status = { + _tag: 'Started', + startTime, + }; + } + + public attribute(key: string, value: unknown): void { + if (!this.sentrySpan.isRecording()) { + return; + } + this.sentrySpan.setAttribute(key, value as Parameters[1]); + this.attributes.set(key, value); + } + + public addLinks(_links: ReadonlyArray): void { + this.links.push(..._links); + } + + public end(endTime: bigint, exit: Exit.Exit): void { + this.status = { + _tag: 'Ended', + endTime, + exit, + startTime: this.status.startTime, + }; + + if (!this.sentrySpan.isRecording()) { + return; + } + + if (Exit.isFailure(exit)) { + const cause = exit.cause; + const message = + cause._tag === 'Fail' ? String(cause.error) : cause._tag === 'Die' ? String(cause.defect) : 'internal_error'; + this.sentrySpan.setStatus({ code: 2, message }); + } else { + this.sentrySpan.setStatus({ code: 1 }); + } + + if (this.ownsSpan) { + this.sentrySpan.end(nanosToHrTime(endTime)); + } + } + + public event(name: string, startTime: bigint, attributes?: Record): void { + if (!this.sentrySpan.isRecording()) { + return; + } + this.sentrySpan.addEvent(name, attributes as Parameters[1], nanosToHrTime(startTime)); + } +} + +function createSentrySpan( + name: string, + parent: Option.Option, + context: Context.Context, + links: ReadonlyArray, + startTime: bigint, + kind: EffectTracer.SpanKind, +): SentrySpanLike { + const parentSentrySpan = + Option.isSome(parent) && isSentrySpan(parent.value) ? parent.value.sentrySpan : (getActiveSpan() ?? null); + + if (kind === 'server' && parentSentrySpan && isHttpServerSpan(parentSentrySpan)) { + return new SentrySpanWrapper(name, parent, context, links, startTime, kind, parentSentrySpan, false); + } + + const newSpan = startInactiveSpan({ + name, + op: KIND_MAP[kind], + startTime: nanosToHrTime(startTime), + ...(parentSentrySpan ? { parentSpan: parentSentrySpan } : {}), + }); + + return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan, true); +} + +const makeSentryTracer = (): EffectTracer.Tracer => + EffectTracer.make({ + span(name, parent, context, links, startTime, kind) { + return createSentrySpan(name, parent, context, links, startTime, kind); + }, + context(execution, fiber) { + const currentSpan = fiber.currentSpan; + if (currentSpan === undefined || !isSentrySpan(currentSpan)) { + return execution(); + } + return withActiveSpan(currentSpan.sentrySpan, execution); + }, + }); + +/** + * Effect Layer that sets up the Sentry tracer for Effect spans. + */ +export const SentryEffectTracerLayer: Layer.Layer = setTracer(makeSentryTracer()); diff --git a/yarn.lock b/yarn.lock index ac89a4468d6a..fd3da4b07d38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14877,6 +14877,14 @@ effect@3.16.12: "@standard-schema/spec" "^1.0.0" fast-check "^3.23.1" +effect@^3.19.19: + version "3.19.19" + resolved "https://registry.yarnpkg.com/effect/-/effect-3.19.19.tgz#643a5a4b7445cc924a28270bc6cd1a5c8facd27e" + integrity sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg== + dependencies: + "@standard-schema/spec" "^1.0.0" + fast-check "^3.23.1" + ejs@^3.1.7: version "3.1.8" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.8.tgz#758d32910c78047585c7ef1f92f9ee041c1c190b" @@ -28096,7 +28104,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2"