Skip to content

Commit ebd2c8a

Browse files
committed
feat(voice): VoiceMetricsReporter typed event bus with isolated listeners
Fans out the 5 lifecycle event types (provider_selected, provider_failed, provider_failover, provider_degraded, provider_unavailable) to subscribers. Listener exceptions are caught so one bad subscriber can't break the others. Host apps pipe these to WebSocket frames for client UX, to log aggregators for ops visibility, or to metrics systems. Task 6/17.
1 parent d9cf23c commit ebd2c8a

2 files changed

Lines changed: 123 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @module voice-pipeline/VoiceMetricsReporter
3+
*
4+
* Typed pub/sub bus for voice-pipeline lifecycle events. Chains and
5+
* circuit breakers emit structured events here; host applications
6+
* subscribe to forward them to clients (WebSocket frames), metrics
7+
* systems (Prometheus, Datadog), or logs.
8+
*
9+
* Listener errors are swallowed — one bad subscriber must not poison the
10+
* fan-out path for others.
11+
*/
12+
13+
import type { HealthErrorClass } from './VoicePipelineError.js';
14+
15+
export type VoiceMetricEvent =
16+
| {
17+
type: 'provider_selected';
18+
kind: 'stt' | 'tts';
19+
providerId: string;
20+
attempt: number;
21+
}
22+
| {
23+
type: 'provider_failed';
24+
kind: 'stt' | 'tts';
25+
providerId: string;
26+
errorClass: HealthErrorClass;
27+
message: string;
28+
}
29+
| {
30+
type: 'provider_failover';
31+
kind: 'stt' | 'tts';
32+
from: string;
33+
to: string;
34+
reason: HealthErrorClass;
35+
lostMs: number;
36+
}
37+
| {
38+
type: 'provider_degraded';
39+
kind: 'stt' | 'tts';
40+
providerId: string;
41+
latencyMs: number;
42+
thresholdMs: number;
43+
}
44+
| {
45+
type: 'provider_unavailable';
46+
kind: 'stt' | 'tts';
47+
checkedProviders: string[];
48+
};
49+
50+
export type VoiceMetricListener = (event: VoiceMetricEvent) => void;
51+
52+
export class VoiceMetricsReporter {
53+
private readonly listeners = new Set<VoiceMetricListener>();
54+
55+
subscribe(fn: VoiceMetricListener): () => void {
56+
this.listeners.add(fn);
57+
return () => {
58+
this.listeners.delete(fn);
59+
};
60+
}
61+
62+
emit(event: VoiceMetricEvent): void {
63+
for (const fn of this.listeners) {
64+
try {
65+
fn(event);
66+
} catch {
67+
/* swallow — one bad listener must not poison the rest */
68+
}
69+
}
70+
}
71+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { VoiceMetricsReporter } from '../VoiceMetricsReporter.js';
3+
4+
describe('VoiceMetricsReporter', () => {
5+
it('fans out events to all subscribers', () => {
6+
const r = new VoiceMetricsReporter();
7+
const a = vi.fn();
8+
const b = vi.fn();
9+
r.subscribe(a);
10+
r.subscribe(b);
11+
r.emit({
12+
type: 'provider_selected',
13+
kind: 'stt',
14+
providerId: 'deepgram',
15+
attempt: 1,
16+
});
17+
expect(a).toHaveBeenCalledOnce();
18+
expect(b).toHaveBeenCalledOnce();
19+
expect(a.mock.calls[0][0].providerId).toBe('deepgram');
20+
});
21+
22+
it('unsubscribe removes a listener', () => {
23+
const r = new VoiceMetricsReporter();
24+
const a = vi.fn();
25+
const unsub = r.subscribe(a);
26+
unsub();
27+
r.emit({
28+
type: 'provider_failover',
29+
kind: 'tts',
30+
from: 'elevenlabs',
31+
to: 'openai',
32+
reason: 'network',
33+
lostMs: 120,
34+
});
35+
expect(a).not.toHaveBeenCalled();
36+
});
37+
38+
it('swallows listener errors without breaking fan-out', () => {
39+
const r = new VoiceMetricsReporter();
40+
const good = vi.fn();
41+
r.subscribe(() => {
42+
throw new Error('boom');
43+
});
44+
r.subscribe(good);
45+
r.emit({
46+
type: 'provider_unavailable',
47+
kind: 'stt',
48+
checkedProviders: ['deepgram', 'elevenlabs'],
49+
});
50+
expect(good).toHaveBeenCalledOnce();
51+
});
52+
});

0 commit comments

Comments
 (0)