@@ -67,6 +67,36 @@ function connectionAttributes(host: string | undefined, port: number | undefined
6767 } ;
6868}
6969
70+ // ioredis re-enters `sendCommand` with the same command object when it drains
71+ // the offline queue on connect which leads to duplicate spans.
72+ // Track commands we've already traced so each logical command produces one span.
73+ const tracedCommands = new WeakSet < object > ( ) ;
74+
75+ /**
76+ * Builds the db span for an `orchestrion:ioredis:command` payload, or returns `undefined` to skip
77+ * it: for a non-command payload, or the offline-queue re-send of an already-traced command.
78+ *
79+ * Exported for unit testing.
80+ */
81+ export function startIORedisCommandSpan ( data : IORedisCommandContext ) : Span | undefined {
82+ const command = data . arguments ?. [ 0 ] as RedisCommand | undefined ;
83+ if ( ! command || typeof command !== 'object' ) {
84+ return undefined ;
85+ }
86+ // guard against duplicate spans
87+ if ( tracedCommands . has ( command ) ) {
88+ return undefined ;
89+ }
90+ tracedCommands . add ( command ) ;
91+ const { host, port } = getConnectionOptions ( data . self ) ;
92+ const statement = defaultDbStatementSerializer ( command . name , command . args ?? [ ] ) ;
93+ return startInactiveSpan ( {
94+ name : statement ,
95+ op : 'db' ,
96+ attributes : { ...connectionAttributes ( host , port ) , [ DB_STATEMENT ] : statement } ,
97+ } ) ;
98+ }
99+
70100const _ioredisChannelIntegration = ( ( options : IORedisChannelIntegrationOptions = { } ) => {
71101 const responseHook = options . responseHook ;
72102
@@ -92,35 +122,19 @@ const _ioredisChannelIntegration = ((options: IORedisChannelIntegrationOptions =
92122 // binding that `initOpenTelemetry()` registers after integration `setupOnce` —
93123 // defer until it's available (matches the native redis diagnostics-channel subscriber).
94124 waitForTracingChannelBinding ( ( ) => {
95- bindTracingChannelToSpan (
96- commandChannel ,
97- data => {
125+ bindTracingChannelToSpan ( commandChannel , startIORedisCommandSpan , {
126+ // ioredis' `requireParentSpan` default: only create a span under an active span.
127+ requiresParentSpan : true ,
128+ beforeSpanEnd ( span , data ) {
129+ if ( 'error' in data || ! responseHook ) {
130+ return ;
131+ }
98132 const command = data . arguments ?. [ 0 ] as RedisCommand | undefined ;
99- if ( ! command || typeof command !== 'object' ) {
100- return undefined ;
133+ if ( command ) {
134+ runResponseHook ( responseHook , span , command , data . result ) ;
101135 }
102- const { host, port } = getConnectionOptions ( data . self ) ;
103- const statement = defaultDbStatementSerializer ( command . name , command . args ?? [ ] ) ;
104- return startInactiveSpan ( {
105- name : statement ,
106- op : 'db' ,
107- attributes : { ...connectionAttributes ( host , port ) , [ DB_STATEMENT ] : statement } ,
108- } ) ;
109136 } ,
110- {
111- // ioredis' `requireParentSpan` default: only create a span under an active span.
112- requiresParentSpan : true ,
113- beforeSpanEnd ( span , data ) {
114- if ( 'error' in data || ! responseHook ) {
115- return ;
116- }
117- const command = data . arguments ?. [ 0 ] as RedisCommand | undefined ;
118- if ( command ) {
119- runResponseHook ( responseHook , span , command , data . result ) ;
120- }
121- } ,
122- } ,
123- ) ;
137+ } ) ;
124138
125139 bindTracingChannelToSpan (
126140 connectChannel ,
0 commit comments