Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { afterAll, describe, expect } from 'vitest';
import { isOrchestrionEnabled } from '../../../utils';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

describe('redis cache auto instrumentation', () => {
afterAll(() => {
cleanupChildProcesses();
});

// Under orchestrion, ioredis <5.11 is instrumented by the diagnostics-channel
// subscriber instead of the OTel monkey-patch, so ioredis span origins differ.
// node-redis (redis-4/redis-5) is not ported, so those keep `auto.db.otel.redis`.
const ioredisOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.redis' : 'auto.db.otel.redis';

describe('ioredis non-cache keys', () => {
const EXPECTED_TRANSACTION = {
transaction: 'Test Span',
spans: expect.arrayContaining([
expect.objectContaining({
description: 'set test-key [1 other arguments]',
op: 'db',
origin: 'auto.db.otel.redis',
origin: ioredisOrigin,
data: expect.objectContaining({
'sentry.op': 'db',
'db.system': 'redis',
Expand All @@ -25,7 +31,7 @@ describe('redis cache auto instrumentation', () => {
expect.objectContaining({
description: 'get test-key',
op: 'db',
origin: 'auto.db.otel.redis',
origin: ioredisOrigin,
data: expect.objectContaining({
'sentry.op': 'db',
'db.system': 'redis',
Expand Down Expand Up @@ -56,9 +62,9 @@ describe('redis cache auto instrumentation', () => {
expect.objectContaining({
description: 'ioredis-cache:test-key',
op: 'cache.put',
origin: 'auto.db.otel.redis',
origin: ioredisOrigin,
data: expect.objectContaining({
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': ioredisOrigin,
'db.statement': 'set ioredis-cache:test-key [1 other arguments]',
'cache.key': ['ioredis-cache:test-key'],
'cache.item_size': 2,
Expand All @@ -70,9 +76,9 @@ describe('redis cache auto instrumentation', () => {
expect.objectContaining({
description: 'ioredis-cache:test-key-set-EX',
op: 'cache.put',
origin: 'auto.db.otel.redis',
origin: ioredisOrigin,
data: expect.objectContaining({
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': ioredisOrigin,
'db.statement': 'set ioredis-cache:test-key-set-EX [3 other arguments]',
'cache.key': ['ioredis-cache:test-key-set-EX'],
'cache.item_size': 2,
Expand All @@ -84,9 +90,9 @@ describe('redis cache auto instrumentation', () => {
expect.objectContaining({
description: 'ioredis-cache:test-key-setex',
op: 'cache.put',
origin: 'auto.db.otel.redis',
origin: ioredisOrigin,
data: expect.objectContaining({
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': ioredisOrigin,
'db.statement': 'setex ioredis-cache:test-key-setex [2 other arguments]',
'cache.key': ['ioredis-cache:test-key-setex'],
'cache.item_size': 2,
Expand All @@ -98,9 +104,9 @@ describe('redis cache auto instrumentation', () => {
expect.objectContaining({
description: 'ioredis-cache:test-key',
op: 'cache.get',
origin: 'auto.db.otel.redis',
origin: ioredisOrigin,
data: expect.objectContaining({
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': ioredisOrigin,
'db.statement': 'get ioredis-cache:test-key',
'cache.hit': true,
'cache.key': ['ioredis-cache:test-key'],
Expand All @@ -113,9 +119,9 @@ describe('redis cache auto instrumentation', () => {
expect.objectContaining({
description: 'ioredis-cache:unavailable-data',
op: 'cache.get',
origin: 'auto.db.otel.redis',
origin: ioredisOrigin,
data: expect.objectContaining({
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': ioredisOrigin,
'db.statement': 'get ioredis-cache:unavailable-data',
'cache.hit': false,
'cache.key': ['ioredis-cache:unavailable-data'],
Expand All @@ -127,9 +133,9 @@ describe('redis cache auto instrumentation', () => {
expect.objectContaining({
description: 'test-key, ioredis-cache:test-key, ioredis-cache:unavailable-data',
op: 'cache.get',
origin: 'auto.db.otel.redis',
origin: ioredisOrigin,
data: expect.objectContaining({
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': ioredisOrigin,
'db.statement': 'mget [3 other arguments]',
'cache.hit': true,
'cache.key': ['test-key', 'ioredis-cache:test-key', 'ioredis-cache:unavailable-data'],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import { afterAll, describe, expect } from 'vitest';
import { isOrchestrionEnabled } from '../../../utils';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

describe('redis auto instrumentation', () => {
afterAll(() => {
cleanupChildProcesses();
});

// Under orchestrion, ioredis <5.11 is instrumented by the diagnostics-channel
// subscriber instead of the OTel monkey-patch, so the span origin differs. All
// other attributes are identical.
const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.redis' : 'auto.db.otel.redis';

const EXPECTED_TRANSACTION = {
transaction: 'Test Span',
spans: expect.arrayContaining([
expect.objectContaining({
description: 'set test-key [1 other arguments]',
op: 'db',
origin: 'auto.db.otel.redis',
origin,
data: expect.objectContaining({
'sentry.op': 'db',
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': origin,
'db.system': 'redis',
'net.peer.name': 'localhost',
'net.peer.port': 6380,
Expand All @@ -25,10 +31,10 @@ describe('redis auto instrumentation', () => {
expect.objectContaining({
description: 'get test-key',
op: 'db',
origin: 'auto.db.otel.redis',
origin,
data: expect.objectContaining({
'sentry.op': 'db',
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': origin,
'db.system': 'redis',
'net.peer.name': 'localhost',
'net.peer.port': 6380,
Expand All @@ -40,10 +46,10 @@ describe('redis auto instrumentation', () => {
description: 'incr test-key',
op: 'db',
status: 'internal_error',
origin: 'auto.db.otel.redis',
origin,
data: expect.objectContaining({
'sentry.op': 'db',
'sentry.origin': 'auto.db.otel.redis',
'sentry.origin': origin,
'db.system': 'redis',
'net.peer.name': 'localhost',
'net.peer.port': 6380,
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,
);
};
Loading
Loading