Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ export default defineNuxtConfig({
nitro: {
// Nuxt's server is built by Nitro (Rollup), not Vite — so the orchestrion
// code transform has to run as a Nitro Rollup plugin to reach `server/api/*`
// routes. Force-bundle ONLY the instrumented deps (`mysql`) via
// `externals.inline`; externalized deps are `require()`d from `node_modules`
// at runtime and never pass through the transform.
// routes. Force-bundle the instrumented deps via `externals.inline`;
// externalized deps are `require()`d from `node_modules` at runtime and never
// pass through the transform.
//
// `standard-as-callback` is ioredis' CJS `export default` helper used by
// `connect()`. Left external, Rollup's interop resolves its `.default` to a
// non-function in the bundle; inlining it alongside ioredis links the
// interop consistently.
externals: {
inline: INSTRUMENTED_MODULE_NAMES,
inline: [...INSTRUMENTED_MODULE_NAMES, 'standard-as-callback'],
},
rollupConfig: {
plugins: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('Instruments ioredis automatically', async ({ baseURL }) => {
// This test works as well without orchestrion
const transactionEventPromise = waitForTransaction('nuxt-4-orchestrion', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-ioredis'
Expand All @@ -21,7 +20,7 @@ test('Instruments ioredis automatically', async ({ baseURL }) => {
expect(spans).toContainEqual(
expect.objectContaining({
op: 'db',
origin: 'auto.db.otel.redis',
origin: 'auto.db.orchestrion.redis',
description: 'set test-key [1 other arguments]',
status: 'ok',
data: expect.objectContaining({
Expand All @@ -33,7 +32,7 @@ test('Instruments ioredis automatically', async ({ baseURL }) => {
expect(spans).toContainEqual(
expect.objectContaining({
op: 'db',
origin: 'auto.db.otel.redis',
origin: 'auto.db.orchestrion.redis',
description: 'get test-key',
status: 'ok',
data: expect.objectContaining({
Expand Down
103 changes: 103 additions & 0 deletions packages/node/src/integrations/tracing/redis/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Span } from '@sentry/core';
import {
SEMANTIC_ATTRIBUTE_CACHE_HIT,
SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE,
SEMANTIC_ATTRIBUTE_CACHE_KEY,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
spanToJSON,
truncate,
} from '@sentry/core';
import type { IORedisCommandArgs } from '../../../utils/redisCache';
import {
calculateCacheItemSize,
GET_COMMANDS,
getCacheKeySafely,
getCacheOperation,
isInCommands,
shouldConsiderForCache,
} from '../../../utils/redisCache';
import type { IORedisResponseCustomAttributeFunction } from './vendored/types';

// This module deliberately does NOT import the vendored OTel `IORedisInstrumentation`/
// `RedisInstrumentation`, so the orchestrion opt-in can pull `cacheResponseHook`
// without dragging the OTel redis instrumentation into its module graph.

export interface RedisOptions {
/**
* Define cache prefixes for cache keys that should be captured as a cache span.
*
* Setting this to, for example, `['user:']` will capture cache keys that start with `user:`.
*/
cachePrefixes?: string[];
/**
* Maximum length of the cache key added to the span description. If the key exceeds this length, it will be truncated.
*
* Passing `0` will use the full cache key without truncation.
*
* By default, the full cache key is used.
*/
maxCacheKeyLength?: number;
}

/* Only exported for testing purposes */
export let _redisOptions: RedisOptions = {};

/** Set the options consumed by {@link cacheResponseHook}. */
export function setRedisOptions(options: RedisOptions): void {
_redisOptions = options;
}

/* Only exported for testing purposes */
export const cacheResponseHook: IORedisResponseCustomAttributeFunction = (
span: Span,
redisCommand: string,
cmdArgs: IORedisCommandArgs,
response: unknown,
) => {
const safeKey = getCacheKeySafely(redisCommand, cmdArgs);
const cacheOperation = getCacheOperation(redisCommand);

if (
!safeKey ||
!cacheOperation ||
!_redisOptions.cachePrefixes ||
!shouldConsiderForCache(redisCommand, safeKey, _redisOptions.cachePrefixes)
) {
// not relevant for cache
return;
}

// otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199
// We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/
// Fall back to stable semconv attributes (server.address/server.port) when
// old-semconv ones are absent, eg OTEL_SEMCONV_STABILITY_OPT_IN=database
// set for node-redis v4/v5.
const spanData = spanToJSON(span).data;
const networkPeerAddress = spanData['net.peer.name'] ?? spanData['server.address'];
const networkPeerPort = spanData['net.peer.port'] ?? spanData['server.port'];
if (networkPeerPort && networkPeerAddress) {
span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort });
}

const cacheItemSize = calculateCacheItemSize(response);

if (cacheItemSize) {
span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize);
}

if (isInCommands(GET_COMMANDS, redisCommand) && cacheItemSize !== undefined) {
span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0);
}

span.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation,
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey,
});

// todo: change to string[] once EAP supports it
const spanDescription = safeKey.join(', ');

span.updateName(
_redisOptions.maxCacheKeyLength ? truncate(spanDescription, _redisOptions.maxCacheKeyLength) : spanDescription,
);
};
113 changes: 16 additions & 97 deletions packages/node/src/integrations/tracing/redis/index.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,20 @@
import type { IntegrationFn, Span } from '@sentry/core';
import {
defineIntegration,
SEMANTIC_ATTRIBUTE_CACHE_HIT,
SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE,
SEMANTIC_ATTRIBUTE_CACHE_KEY,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
spanToJSON,
truncate,
waitForTracingChannelBinding,
} from '@sentry/core';
import type { IntegrationFn } from '@sentry/core';
import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core';
import * as dc from 'node:diagnostics_channel';
import { subscribeRedisDiagnosticChannels, type RedisTracingChannelFactory } from '@sentry/server-utils';
import { generateInstrumentOnce } from '@sentry/node-core';
import type { IORedisCommandArgs } from '../../../utils/redisCache';
import {
calculateCacheItemSize,
GET_COMMANDS,
getCacheKeySafely,
getCacheOperation,
isInCommands,
shouldConsiderForCache,
} from '../../../utils/redisCache';
import type { IORedisResponseCustomAttributeFunction } from './vendored/types';
import { isDiagnosticsChannelInjectionEnabled } from '../../../sdk/diagnosticsChannelInjection';
import { cacheResponseHook, type RedisOptions, setRedisOptions } from './cache';
import { IORedisInstrumentation } from './vendored/ioredis-instrumentation';
import { RedisInstrumentation } from './vendored/redis-instrumentation';

interface RedisOptions {
/**
* Define cache prefixes for cache keys that should be captured as a cache span.
*
* Setting this to, for example, `['user:']` will capture cache keys that start with `user:`.
*/
cachePrefixes?: string[];
/**
* Maximum length of the cache key added to the span description. If the key exceeds this length, it will be truncated.
*
* Passing `0` will use the full cache key without truncation.
*
* By default, the full cache key is used.
*/
maxCacheKeyLength?: number;
}
// `cacheResponseHook`/`_redisOptions` live in `./cache` (which has no OTel
// instrumentation imports) so the orchestrion opt-in can pull the hook without
// dragging the OTel redis instrumentation in. Re-exported here for tests.
export { _redisOptions, cacheResponseHook } from './cache';

const INTEGRATION_NAME = 'Redis' as const;

/* Only exported for testing purposes */
export let _redisOptions: RedisOptions = {};

/* Only exported for testing purposes */
export const cacheResponseHook: IORedisResponseCustomAttributeFunction = (
span: Span,
redisCommand: string,
cmdArgs: IORedisCommandArgs,
response: unknown,
) => {
const safeKey = getCacheKeySafely(redisCommand, cmdArgs);
const cacheOperation = getCacheOperation(redisCommand);

if (
!safeKey ||
!cacheOperation ||
!_redisOptions.cachePrefixes ||
!shouldConsiderForCache(redisCommand, safeKey, _redisOptions.cachePrefixes)
) {
// not relevant for cache
return;
}

// otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199
// We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/
// Fall back to stable semconv attributes (server.address/server.port) when
// old-semconv ones are absent, eg OTEL_SEMCONV_STABILITY_OPT_IN=database
// set for node-redis v4/v5.
const spanData = spanToJSON(span).data;
const networkPeerAddress = spanData['net.peer.name'] ?? spanData['server.address'];
const networkPeerPort = spanData['net.peer.port'] ?? spanData['server.port'];
if (networkPeerPort && networkPeerAddress) {
span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort });
}

const cacheItemSize = calculateCacheItemSize(response);

if (cacheItemSize) {
span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize);
}

if (isInCommands(GET_COMMANDS, redisCommand) && cacheItemSize !== undefined) {
span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0);
}

span.setAttributes({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation,
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey,
});

// todo: change to string[] once EAP supports it
const spanDescription = safeKey.join(', ');

span.updateName(
_redisOptions.maxCacheKeyLength ? truncate(spanDescription, _redisOptions.maxCacheKeyLength) : spanDescription,
);
};

const instrumentIORedis = generateInstrumentOnce(`${INTEGRATION_NAME}.IORedis`, () => {
return new IORedisInstrumentation({
responseHook: cacheResponseHook,
Expand All @@ -120,7 +33,13 @@ const instrumentRedisModule = generateInstrumentOnce(`${INTEGRATION_NAME}.Redis`
*/
export const instrumentRedis = Object.assign(
(): void => {
instrumentIORedis();
// When diagnostics-channel injection is opted in, orchestrion owns ioredis
// `<5.11.0`, so skip the OTel ioredis monkey-patch to avoid double instrumentation.
// On Node without `tracingChannel` (<18.19) orchestrion can't run, so keep the
// OTel patch there — otherwise ioredis `<5.11.0` would not be traced at all.
if (!isDiagnosticsChannelInjectionEnabled() || !dc.tracingChannel) {
instrumentIORedis();
}
Comment thread
chargome marked this conversation as resolved.
Comment thread
chargome marked this conversation as resolved.
Comment thread
chargome marked this conversation as resolved.
instrumentRedisModule();
// node-redis >= 5.12.0 and ioredis >= 5.11.0 publish via diagnostics_channel.
// `bindTracingChannelToSpan` (inside the subscriber) makes the span the active
Expand All @@ -144,7 +63,7 @@ const _redisIntegration = ((options: RedisOptions = {}) => {
return {
name: INTEGRATION_NAME,
setupOnce() {
_redisOptions = options;
setRedisOptions(options);
instrumentRedis();
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,54 +5,10 @@
* NOTICE from the Sentry authors:
* - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/instrumentation-redis-v0.62.0/packages/redis-common
* - Upstream version: @opentelemetry/redis-common@0.38.2
* - Minor TypeScript adjustments for this repository's compiler settings
*/
/* eslint-disable -- vendored @opentelemetry/redis-common */

/**
* List of regexes and the number of arguments that should be serialized for matching commands.
* For example, HSET should serialize which key and field it's operating on, but not its value.
* Setting the subset to -1 will serialize all arguments.
* Commands without a match will have their first argument serialized.
*
* Refer to https://redis.io/commands/ for the full list.
* The implementation lives in `@sentry/server-utils` (shared with the orchestrion
* ioredis subscriber). Re-exported here to keep the import path stable for the
* vendored redis/ioredis instrumentations.
*/
const serializationSubsets = [
{
regex: /^ECHO/i,
args: 0,
},
{
regex: /^(LPUSH|MSET|PFA|PUBLISH|RPUSH|SADD|SET|SPUBLISH|XADD|ZADD)/i,
args: 1,
},
{
regex: /^(HSET|HMSET|LSET|LINSERT)/i,
args: 2,
},
{
regex:
/^(ACL|BIT|B[LRZ]|CLIENT|CLUSTER|CONFIG|COMMAND|DECR|DEL|EVAL|EX|FUNCTION|GEO|GET|HINCR|HMGET|HSCAN|INCR|L[TRLM]|MEMORY|P[EFISTU]|RPOP|S[CDIMORSU]|XACK|X[CDGILPRT]|Z[CDILMPRS])/i,
args: -1,
},
];

/**
* Given the redis command name and arguments, return a combination of the
* command name + the allowed arguments according to `serializationSubsets`.
*/
export const defaultDbStatementSerializer = (
cmdName: string,
cmdArgs: Array<string | Buffer | number | any[]>,
): string => {
if (Array.isArray(cmdArgs) && cmdArgs.length) {
const nArgsToSerialize = serializationSubsets.find(({ regex }) => regex.test(cmdName))?.args ?? 0;
const argsToSerialize: Array<string | Buffer | number | any[]> =
nArgsToSerialize >= 0 ? cmdArgs.slice(0, nArgsToSerialize) : cmdArgs.slice();
if (cmdArgs.length > argsToSerialize.length) {
argsToSerialize.push(`[${cmdArgs.length - nArgsToSerialize} other arguments]`);
}
return `${cmdName} ${argsToSerialize.join(' ')}`;
}
return cmdName;
};
export { defaultDbStatementSerializer } from '@sentry/server-utils';
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
mysqlChannelIntegration,
lruMemoizerChannelIntegration,
ioredisChannelIntegration,
detectOrchestrionSetup,
} from '@sentry/server-utils/orchestrion';
import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register';
import { cacheResponseHook } from '../integrations/tracing/redis/cache';
import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection';
import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection';

Expand Down Expand Up @@ -41,12 +43,15 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject
*/
export function experimentalUseDiagnosticsChannelInjection(): void {
setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => {
const integrations = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const;
const replacedOtelIntegrationNames = integrations.map(i => i.name);
// These channel integrations 1:1 replace the OTel integration of the same name.
const replacements = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const;

return {
integrations,
replacedOtelIntegrationNames,
// ioredis only supersedes the ioredis monkey-patch inside the composite OTel
// `Redis` integration (gated off in `redisIntegration`), so it's added here
// but kept out of `replacedOtelIntegrationNames` — `Redis` must stay.
integrations: [...replacements, ioredisChannelIntegration({ responseHook: cacheResponseHook })],
replacedOtelIntegrationNames: replacements.map(i => i.name),
register: registerDiagnosticsChannelInjection,
detect: detectOrchestrionSetup,
};
Expand Down
Loading
Loading