Skip to content

Commit 9112d23

Browse files
committed
fix(observability): scope connection ages to the live client and detect IPv6
Two corrections to the diagnostic snapshot, both cases where it would have answered wrongly. Ages now report null once the cached client is gone. Discarding a client — a forced reconnect, or an explicit close — left its timestamps behind until the next getRedisClient() rebuilt them, so the snapshot dated a connection that no longer existed while reporting no-client beside it. The lifecycle counters stay cumulative for the process, which is intended; only the ages are per-client. Host classification now uses node:net isIP rather than an IPv4 regex, so an IPv6 literal reads as an IP instead of a DNS name. Getting that backwards would send someone chasing DNS resolution on an endpoint that performs none, which is the exact misdirection the field exists to prevent. sniOverride still mirrors resolveRedisTlsOptions and stays IPv4-only, so an IPv6 host over TLS reports hostKind ip with sniOverride false — the useful reading, since that pairing is a connection whose certificate cannot verify.
1 parent 702b9ce commit 9112d23

2 files changed

Lines changed: 55 additions & 5 deletions

File tree

apps/sim/lib/core/config/redis.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,35 @@ describe('redis config', () => {
225225
expect(JSON.stringify(d)).not.toContain('10.0.0.5')
226226
})
227227

228+
it('does not date a connection that has been discarded', async () => {
229+
mockRedisInstance.status = 'ready'
230+
getRedisClient()
231+
expect(describeRedisConnection().clientAgeMs).not.toBeNull()
232+
233+
// Two consecutive PING failures drop the cached client.
234+
mockRedisInstance.ping.mockRejectedValue(new Error('ETIMEDOUT'))
235+
await vi.advanceTimersByTimeAsync(15_000)
236+
await vi.advanceTimersByTimeAsync(15_000)
237+
238+
const d = describeRedisConnection()
239+
expect(d.status).toBe('no-client')
240+
expect(d.clientAgeMs).toBeNull()
241+
expect(d.readyAgeMs).toBeNull()
242+
expect(d.msSinceLastPingOk).toBeNull()
243+
// Lifecycle counters stay cumulative for the process.
244+
expect(d.reconnects).toBeGreaterThanOrEqual(0)
245+
})
246+
247+
it('classifies an IPv6 literal as an IP, not a DNS name', () => {
248+
mockEnv.REDIS_URL = 'rediss://[2600:1f18::1]:6379'
249+
250+
const d = describeRedisConnection()
251+
252+
expect(d.hostKind).toBe('ip')
253+
// Mirrors resolveRedisTlsOptions, which applies the override for IPv4 only.
254+
expect(d.sniOverride).toBe(false)
255+
})
256+
228257
it('reports a DNS host so resolution latency can be ruled in or out', () => {
229258
mockEnv.REDIS_URL = 'rediss://primary.example.cache.amazonaws.com:6379'
230259

apps/sim/lib/core/config/redis.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isIP } from 'node:net'
12
import { createLogger } from '@sim/logger'
23
import { toError } from '@sim/utils/errors'
34
import { randomFloat } from '@sim/utils/random'
@@ -133,9 +134,20 @@ function describeRedisUrl(
133134
if (!url) return { hostKind: 'unknown', tls: false, sniOverride: false }
134135
try {
135136
const parsed = new URL(url)
136-
const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(parsed.hostname)
137+
// WHATWG keeps IPv6 literals bracketed in `hostname`; `isIP` wants them bare.
138+
const host = parsed.hostname.replace(/^\[|\]$/g, '')
137139
const tls = parsed.protocol === 'rediss:'
138-
return { hostKind: isIp ? 'ip' : 'dns', tls, sniOverride: tls && isIp }
140+
/**
141+
* `sniOverride` deliberately mirrors `resolveRedisTlsOptions`, which tests
142+
* for IPv4 only. So an IPv6 literal over TLS reports `hostKind: 'ip'` with
143+
* `sniOverride: false` — not a contradiction but the useful reading, since
144+
* that combination is a connection whose certificate cannot verify.
145+
*/
146+
return {
147+
hostKind: isIP(host) === 0 ? 'dns' : 'ip',
148+
tls,
149+
sniOverride: tls && isIP(host) === 4,
150+
}
139151
} catch {
140152
return { hostKind: 'unknown', tls: false, sniOverride: false }
141153
}
@@ -160,11 +172,20 @@ export function describeRedisConnection(): RedisConnectionDiagnostics {
160172
// double-cast-allowed: ioredis omits offlineQueue from its public type, and its depth is what separates a command waiting on connection setup from one written to a live socket
161173
const queued = (client as unknown as OfflineQueueView | null)?.offlineQueue?.length
162174

175+
/**
176+
* Ages describe the client that is currently held. A discarded client leaves
177+
* its timestamps behind until the next `getRedisClient()` rebuilds them, and
178+
* reporting those against `no-client` would date a connection that no longer
179+
* exists — precisely the wrong answer for the investigation this exists to
180+
* support. The counters below are deliberately cumulative for the process.
181+
*/
182+
const ageOf = (at: number | null) => (client === null || at === null ? null : now - at)
183+
163184
return {
164185
status: client?.status ?? 'no-client',
165-
clientAgeMs: state.clientCreatedAt === null ? null : now - state.clientCreatedAt,
166-
readyAgeMs: state.lastReadyAt === null ? null : now - state.lastReadyAt,
167-
msSinceLastPingOk: state.lastPingOkAt === null ? null : now - state.lastPingOkAt,
186+
clientAgeMs: ageOf(state.clientCreatedAt),
187+
readyAgeMs: ageOf(state.lastReadyAt),
188+
msSinceLastPingOk: ageOf(state.lastPingOkAt),
168189
queuedCommands: typeof queued === 'number' ? queued : null,
169190
connects: state.connects,
170191
reconnects: state.reconnects,

0 commit comments

Comments
 (0)