@uniai-fe/uds-templates 0.6.8 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,6 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
4
+ import {
5
+ getCctvDebugKeyLabel,
6
+ getCctvDebugUrlLabel,
7
+ logCctvDebugEvent,
8
+ } from "../utils/debug";
4
9
 
5
10
  interface CctvRtcStreamSnapshot {
6
11
  connectionState: RTCPeerConnectionState;
@@ -47,6 +52,18 @@ const notifyEntry = (entry: CctvRtcStreamEntry) => {
47
52
  };
48
53
 
49
54
  const closeEntry = (entry: CctvRtcStreamEntry) => {
55
+ logCctvDebugEvent({
56
+ event: "entry:close",
57
+ payload: {
58
+ attachedVideoCount: entry.attachedVideos.size,
59
+ connectionState: entry.connectionState,
60
+ identityKey: getCctvDebugKeyLabel(entry.identityKey),
61
+ isStreaming: entry.isStreaming,
62
+ streamKey: getCctvDebugKeyLabel(entry.streamKey),
63
+ streamTrackCount: entry.stream?.getTracks().length ?? 0,
64
+ },
65
+ source: "streamRegistry",
66
+ });
50
67
  entry.controller?.abort();
51
68
  entry.controller = null;
52
69
  entry.handle?.close();
@@ -110,6 +127,15 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
110
127
  entries.clear();
111
128
  },
112
129
  closeByIdentity(identityKey, exceptStreamKey) {
130
+ logCctvDebugEvent({
131
+ event: "identity:close",
132
+ payload: {
133
+ entryCount: entries.size,
134
+ exceptStreamKey: getCctvDebugKeyLabel(exceptStreamKey),
135
+ identityKey: getCctvDebugKeyLabel(identityKey),
136
+ },
137
+ source: "streamRegistry",
138
+ });
113
139
  entries.forEach(entry => {
114
140
  if (
115
141
  entry.identityKey === identityKey &&
@@ -124,9 +150,22 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
124
150
  },
125
151
  start({ endpoint, identityKey, streamKey, token, video }) {
126
152
  const existingEntry = entries.get(streamKey);
127
- if (existingEntry) return;
153
+ if (existingEntry) {
154
+ logCctvDebugEvent({
155
+ event: "start:reuse-existing",
156
+ payload: {
157
+ connectionState: existingEntry.connectionState,
158
+ identityKey: getCctvDebugKeyLabel(identityKey),
159
+ isStreaming: existingEntry.isStreaming,
160
+ streamKey: getCctvDebugKeyLabel(streamKey),
161
+ },
162
+ source: "streamRegistry",
163
+ });
164
+ return;
165
+ }
128
166
 
129
167
  const controller = new AbortController();
168
+ const startedAt = Date.now();
130
169
  const entry: CctvRtcStreamEntry = {
131
170
  attachedVideos: new Set([video]),
132
171
  connectionState: "connecting",
@@ -142,6 +181,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
142
181
 
143
182
  entries.set(streamKey, entry);
144
183
  attachVideo(video, null);
184
+ logCctvDebugEvent({
185
+ event: "start:create-entry",
186
+ payload: {
187
+ attachedVideoCount: entry.attachedVideos.size,
188
+ endpoint: getCctvDebugUrlLabel(endpoint),
189
+ identityKey: getCctvDebugKeyLabel(identityKey),
190
+ streamKey: getCctvDebugKeyLabel(streamKey),
191
+ },
192
+ source: "streamRegistry",
193
+ });
145
194
 
146
195
  startWhepStream({
147
196
  endpoint,
@@ -150,10 +199,33 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
150
199
  signal: controller.signal,
151
200
  onConnectionStateChange: state => {
152
201
  entry.connectionState = state;
202
+ logCctvDebugEvent({
203
+ event: "connection-state:change",
204
+ payload: {
205
+ elapsedMs: Date.now() - startedAt,
206
+ identityKey: getCctvDebugKeyLabel(identityKey),
207
+ state,
208
+ streamKey: getCctvDebugKeyLabel(streamKey),
209
+ },
210
+ source: "streamRegistry",
211
+ });
153
212
  notifyEntry(entry);
154
213
  },
155
214
  onTrack: event => {
156
215
  entry.stream = event.streams[0] ?? null;
216
+ logCctvDebugEvent({
217
+ event: "track:received",
218
+ payload: {
219
+ elapsedMs: Date.now() - startedAt,
220
+ identityKey: getCctvDebugKeyLabel(identityKey),
221
+ streamId: entry.stream?.id ?? null,
222
+ streamKey: getCctvDebugKeyLabel(streamKey),
223
+ trackId: event.track.id,
224
+ trackKind: event.track.kind,
225
+ trackReadyState: event.track.readyState,
226
+ },
227
+ source: "streamRegistry",
228
+ });
157
229
  entry.attachedVideos.forEach(attachedVideo =>
158
230
  attachVideo(attachedVideo, entry.stream),
159
231
  );
@@ -163,6 +235,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
163
235
  .then(handle => {
164
236
  entry.handle = handle;
165
237
  entry.isStreaming = false;
238
+ logCctvDebugEvent({
239
+ event: "start:resolved",
240
+ level: "info",
241
+ payload: {
242
+ elapsedMs: Date.now() - startedAt,
243
+ identityKey: getCctvDebugKeyLabel(identityKey),
244
+ streamKey: getCctvDebugKeyLabel(streamKey),
245
+ },
246
+ source: "streamRegistry",
247
+ });
166
248
  notifyEntry(entry);
167
249
  })
168
250
  .catch(error => {
@@ -174,6 +256,17 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
174
256
  error instanceof Error
175
257
  ? error.message
176
258
  : "스트림 연결에 실패했습니다.";
259
+ logCctvDebugEvent({
260
+ event: "start:rejected",
261
+ level: "error",
262
+ payload: {
263
+ elapsedMs: Date.now() - startedAt,
264
+ errorMessage: entry.streamError,
265
+ identityKey: getCctvDebugKeyLabel(identityKey),
266
+ streamKey: getCctvDebugKeyLabel(streamKey),
267
+ },
268
+ source: "streamRegistry",
269
+ });
177
270
  entry.attachedVideos.forEach(attachedVideo =>
178
271
  attachVideo(attachedVideo, null),
179
272
  );
@@ -19,6 +19,11 @@ import type {
19
19
  UseCctvRtcStreamReturn,
20
20
  } from "../types";
21
21
  import { cctvRtcLiveRegistryAtom } from "../jotai/rtc";
22
+ import {
23
+ getCctvDebugKeyLabel,
24
+ getCctvDebugStackTrace,
25
+ logCctvDebugEvent,
26
+ } from "../utils/debug";
22
27
  import { getIsLive } from "../utils/video-state";
23
28
  import { useFormContext, useWatch } from "react-hook-form";
24
29
 
@@ -236,9 +241,23 @@ export function useCctvRtcStream({
236
241
  return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
237
242
  }, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
238
243
 
244
+ const debugBasePayload = useMemo(
245
+ () => ({
246
+ camId: cam?.cam_id,
247
+ companyId: cam?.company_id,
248
+ streamIdentityKey: getCctvDebugKeyLabel(streamIdentityKey),
249
+ }),
250
+ [cam?.cam_id, cam?.company_id, streamIdentityKey],
251
+ );
252
+
239
253
  useEffect(() => {
254
+ logCctvDebugEvent({
255
+ event: "identity:reset-has-connected",
256
+ payload: debugBasePayload,
257
+ source: "useRtcStream",
258
+ });
240
259
  setHasConnected(false);
241
- }, [streamIdentityKey]);
260
+ }, [debugBasePayload, streamIdentityKey]);
242
261
 
243
262
  const reconnectReason = useMemo(
244
263
  () =>
@@ -264,20 +283,49 @@ export function useCctvRtcStream({
264
283
  }
265
284
 
266
285
  setPostConnectedReconnectReady(false);
286
+ const reconnectDelayMs = getPostConnectedReconnectDelayMs(
287
+ `${streamIdentityKey}|${reconnectReason}`,
288
+ );
267
289
 
268
- const timeout = setTimeout(
269
- () => {
270
- setPostConnectedReconnectReady(true);
290
+ logCctvDebugEvent({
291
+ event: "reconnect-gate:timer-start",
292
+ payload: {
293
+ ...debugBasePayload,
294
+ isStreaming,
295
+ isTokenLoading,
296
+ reconnectDelayMs,
297
+ reconnectReason,
271
298
  },
272
- getPostConnectedReconnectDelayMs(
273
- `${streamIdentityKey}|${reconnectReason}`,
274
- ),
275
- );
299
+ source: "useRtcStream",
300
+ });
301
+
302
+ const timeout = setTimeout(() => {
303
+ setPostConnectedReconnectReady(true);
304
+ logCctvDebugEvent({
305
+ event: "reconnect-gate:ready",
306
+ payload: {
307
+ ...debugBasePayload,
308
+ reconnectDelayMs,
309
+ reconnectReason,
310
+ },
311
+ source: "useRtcStream",
312
+ });
313
+ }, reconnectDelayMs);
276
314
 
277
315
  return () => {
278
316
  clearTimeout(timeout);
317
+ logCctvDebugEvent({
318
+ event: "reconnect-gate:timer-cancel",
319
+ payload: {
320
+ ...debugBasePayload,
321
+ reconnectDelayMs,
322
+ reconnectReason,
323
+ },
324
+ source: "useRtcStream",
325
+ });
279
326
  };
280
327
  }, [
328
+ debugBasePayload,
281
329
  hasConnected,
282
330
  isStreaming,
283
331
  isTokenLoading,
@@ -343,12 +391,23 @@ export function useCctvRtcStream({
343
391
  }
344
392
 
345
393
  staleTokenRefreshKeyRef.current = streamKeyCandidate;
394
+ logCctvDebugEvent({
395
+ event: "token:stale-refetch",
396
+ payload: {
397
+ ...debugBasePayload,
398
+ dataUpdatedAt: tokenQuery.dataUpdatedAt,
399
+ streamKey: getCctvDebugKeyLabel(streamKeyCandidate),
400
+ },
401
+ source: "useRtcStream",
402
+ });
346
403
  void refetchRtcToken();
347
404
  }, [
348
405
  canUseTokenForStream,
406
+ debugBasePayload,
349
407
  refetchRtcToken,
350
408
  streamKeyCandidate,
351
409
  tokenQuery.data?.token,
410
+ tokenQuery.dataUpdatedAt,
352
411
  tokenQuery.isError,
353
412
  tokenQuery.isFetching,
354
413
  ]);
@@ -359,6 +418,15 @@ export function useCctvRtcStream({
359
418
 
360
419
  // 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
361
420
  if (tokenQuery.isError || !cam?.cam_online) {
421
+ logCctvDebugEvent({
422
+ event: "stream-effect:cleanup-token-or-offline",
423
+ payload: {
424
+ ...debugBasePayload,
425
+ camOnline: cam?.cam_online,
426
+ isTokenError: tokenQuery.isError,
427
+ },
428
+ source: "useRtcStream",
429
+ });
362
430
  if (streamIdentityKey) {
363
431
  streamRegistry.closeByIdentity(streamIdentityKey);
364
432
  }
@@ -377,12 +445,30 @@ export function useCctvRtcStream({
377
445
  activeStreamIdentityKeyRef.current === streamIdentityKey &&
378
446
  activeStreamKeyRef.current !== streamKey
379
447
  ) {
448
+ logCctvDebugEvent({
449
+ event: "stream-effect:close-previous-identity",
450
+ payload: {
451
+ ...debugBasePayload,
452
+ activeStreamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
453
+ nextStreamKey: getCctvDebugKeyLabel(streamKey),
454
+ },
455
+ source: "useRtcStream",
456
+ });
380
457
  streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
381
458
  }
382
459
 
383
460
  activeStreamKeyRef.current = streamKey;
384
461
  activeStreamIdentityKeyRef.current = streamIdentityKey;
385
462
 
463
+ logCctvDebugEvent({
464
+ event: "stream-effect:start",
465
+ payload: {
466
+ ...debugBasePayload,
467
+ streamKey: getCctvDebugKeyLabel(streamKey),
468
+ },
469
+ source: "useRtcStream",
470
+ });
471
+
386
472
  streamRegistry.start({
387
473
  streamKey,
388
474
  identityKey: streamIdentityKey,
@@ -396,7 +482,17 @@ export function useCctvRtcStream({
396
482
  setConnectionState(snapshot.connectionState);
397
483
  setStreamError(snapshot.streamError);
398
484
  setStreaming(snapshot.isStreaming);
399
- if (snapshot.connectionState === "connected") setHasConnected(true);
485
+ if (snapshot.connectionState === "connected") {
486
+ logCctvDebugEvent({
487
+ event: "stream-effect:has-connected",
488
+ payload: {
489
+ ...debugBasePayload,
490
+ streamKey: getCctvDebugKeyLabel(streamKey),
491
+ },
492
+ source: "useRtcStream",
493
+ });
494
+ setHasConnected(true);
495
+ }
400
496
  if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
401
497
  currentVideo.srcObject = snapshot.stream;
402
498
  }
@@ -413,10 +509,19 @@ export function useCctvRtcStream({
413
509
 
414
510
  // effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
415
511
  return () => {
512
+ logCctvDebugEvent({
513
+ event: "stream-effect:detach-video",
514
+ payload: {
515
+ ...debugBasePayload,
516
+ streamKey: getCctvDebugKeyLabel(streamKey),
517
+ },
518
+ source: "useRtcStream",
519
+ });
416
520
  unsubscribe();
417
521
  detachVideo();
418
522
  };
419
523
  }, [
524
+ debugBasePayload,
420
525
  endpoint,
421
526
  streamIdentityKey,
422
527
  streamKey,
@@ -426,10 +531,56 @@ export function useCctvRtcStream({
426
531
  cam?.cam_online,
427
532
  ]);
428
533
 
534
+ const debugReconnectStateRef = useRef({
535
+ connectionState,
536
+ debugBasePayload,
537
+ isPostConnectedReconnectReady,
538
+ reconnectReason,
539
+ streamError,
540
+ });
541
+ debugReconnectStateRef.current = {
542
+ connectionState,
543
+ debugBasePayload,
544
+ isPostConnectedReconnectReady,
545
+ reconnectReason,
546
+ streamError,
547
+ };
548
+
429
549
  const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
430
550
  async options => {
551
+ const debugState = debugReconnectStateRef.current;
552
+ logCctvDebugEvent({
553
+ event: "reconnect-stream:start",
554
+ payload: {
555
+ ...debugState.debugBasePayload,
556
+ connectionState: debugState.connectionState,
557
+ callerStack: getCctvDebugStackTrace(),
558
+ isPostConnectedReconnectReady:
559
+ debugState.isPostConnectedReconnectReady,
560
+ reconnectReason: debugState.reconnectReason,
561
+ streamError: debugState.streamError,
562
+ },
563
+ source: "useRtcStream",
564
+ });
431
565
  const result = await refetchRtcToken(options);
566
+ const resultDebugState = debugReconnectStateRef.current;
567
+ logCctvDebugEvent({
568
+ event: "reconnect-stream:token-result",
569
+ level: result.isSuccess ? "info" : "warn",
570
+ payload: {
571
+ ...resultDebugState.debugBasePayload,
572
+ isError: result.isError,
573
+ isSuccess: result.isSuccess,
574
+ status: result.status,
575
+ },
576
+ source: "useRtcStream",
577
+ });
432
578
  if (result.isSuccess && streamIdentityKey) {
579
+ logCctvDebugEvent({
580
+ event: "reconnect-stream:close-identity",
581
+ payload: resultDebugState.debugBasePayload,
582
+ source: "useRtcStream",
583
+ });
433
584
  streamRegistry.closeByIdentity(streamIdentityKey);
434
585
  }
435
586
  return result;
@@ -469,6 +620,16 @@ export function useCctvRtcStream({
469
620
  return;
470
621
 
471
622
  lastAutoReconnectAtRef.current = now;
623
+ logCctvDebugEvent({
624
+ event: "focus:auto-reconnect",
625
+ payload: {
626
+ ...debugBasePayload,
627
+ canReconnect,
628
+ connectionState,
629
+ reconnectReason,
630
+ },
631
+ source: "useRtcStream",
632
+ });
472
633
  void reconnectStream();
473
634
  };
474
635
 
@@ -479,7 +640,13 @@ export function useCctvRtcStream({
479
640
  window.removeEventListener("focus", reconnectOnFocus);
480
641
  document.removeEventListener("visibilitychange", reconnectOnFocus);
481
642
  };
482
- }, [canReconnect, reconnectStream]);
643
+ }, [
644
+ canReconnect,
645
+ connectionState,
646
+ debugBasePayload,
647
+ reconnectReason,
648
+ reconnectStream,
649
+ ]);
483
650
 
484
651
  const liveState = useMemo(
485
652
  () =>
@@ -503,6 +670,40 @@ export function useCctvRtcStream({
503
670
  ],
504
671
  );
505
672
 
673
+ useEffect(() => {
674
+ logCctvDebugEvent({
675
+ event: "hook-state:update",
676
+ payload: {
677
+ ...debugBasePayload,
678
+ canReconnect,
679
+ connectionState,
680
+ displayConnectionState,
681
+ hasConnected,
682
+ isPostConnectedReconnectReady,
683
+ isStreaming,
684
+ isTokenError,
685
+ isTokenLoading,
686
+ liveState,
687
+ reconnectReason,
688
+ streamError,
689
+ },
690
+ source: "useRtcStream",
691
+ });
692
+ }, [
693
+ canReconnect,
694
+ connectionState,
695
+ debugBasePayload,
696
+ displayConnectionState,
697
+ hasConnected,
698
+ isPostConnectedReconnectReady,
699
+ isStreaming,
700
+ isTokenError,
701
+ isTokenLoading,
702
+ liveState,
703
+ reconnectReason,
704
+ streamError,
705
+ ]);
706
+
506
707
  useEffect(() => {
507
708
  const camId = cam?.cam_id;
508
709
  if (!camId) return;
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ export type CctvDebugEventLevel = "debug" | "info" | "warn" | "error";
4
+
5
+ export interface CctvDebugEvent {
6
+ at: string;
7
+ event: string;
8
+ level: CctvDebugEventLevel;
9
+ payload?: Record<string, unknown>;
10
+ seq: number;
11
+ source: string;
12
+ }
13
+
14
+ export interface CctvDebugBuffer {
15
+ clear: () => void;
16
+ dump: () => CctvDebugEvent[];
17
+ enabled: true;
18
+ events: CctvDebugEvent[];
19
+ limit: number;
20
+ sequence: number;
21
+ }
22
+
23
+ declare global {
24
+ interface Window {
25
+ __UDS_CCTV_DEBUG__?: CctvDebugBuffer;
26
+ }
27
+ }
28
+
29
+ const DEBUG_QUERY_KEYS = ["udsCctvDebug", "cctvDebug"] as const;
30
+ const DEBUG_STORAGE_KEYS = ["uds:cctv:debug", "UDS_CCTV_DEBUG"] as const;
31
+ const DEBUG_ENABLED_VALUES = new Set(["1", "true", "yes", "on", "debug"]);
32
+ const DEBUG_BUFFER_LIMIT = 1000;
33
+
34
+ const isBrowser = (): boolean =>
35
+ typeof window !== "undefined" && typeof document !== "undefined";
36
+
37
+ const isDebugValueEnabled = (value: string | null): boolean =>
38
+ value === "" ||
39
+ (typeof value === "string" && DEBUG_ENABLED_VALUES.has(value));
40
+
41
+ /**
42
+ * identity/stream key를 원문 대신 추적 가능한 hash label로 바꾼다.
43
+ */
44
+ export const getCctvDebugKeyLabel = (
45
+ value: string | null | undefined,
46
+ ): string | null => {
47
+ if (!value) return null;
48
+
49
+ let hash = 0;
50
+ for (let i = 0; i < value.length; i += 1) {
51
+ hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
52
+ }
53
+
54
+ return `hash:${hash.toString(36)}|len:${value.length}`;
55
+ };
56
+
57
+ /**
58
+ * endpoint는 query string을 제거한 origin/path만 기록한다.
59
+ */
60
+ export const getCctvDebugUrlLabel = (
61
+ value: string | null | undefined,
62
+ ): string | null => {
63
+ if (!value) return null;
64
+
65
+ try {
66
+ const url = new URL(value);
67
+ return `${url.origin}${url.pathname}`;
68
+ } catch {
69
+ return value.split("?")[0] ?? null;
70
+ }
71
+ };
72
+
73
+ /**
74
+ * CCTV debug logging 활성화 여부를 확인한다.
75
+ * @desc
76
+ * production default는 off다. `?udsCctvDebug=1` 또는
77
+ * `localStorage.setItem("uds:cctv:debug", "1")`로 runtime에서 켤 수 있다.
78
+ */
79
+ export const isCctvDebugEnabled = (): boolean => {
80
+ if (!isBrowser()) return false;
81
+
82
+ const query = new URLSearchParams(window.location.search);
83
+ for (const key of DEBUG_QUERY_KEYS) {
84
+ if (query.has(key) && isDebugValueEnabled(query.get(key))) return true;
85
+ }
86
+
87
+ for (const key of DEBUG_STORAGE_KEYS) {
88
+ try {
89
+ if (isDebugValueEnabled(window.localStorage.getItem(key))) return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ return false;
96
+ };
97
+
98
+ /**
99
+ * reconnect 호출 주체를 좁히기 위한 stack trace 일부를 만든다.
100
+ */
101
+ export const getCctvDebugStackTrace = (): string[] | null => {
102
+ if (!isCctvDebugEnabled()) return null;
103
+
104
+ const stack = new Error().stack;
105
+ if (!stack) return null;
106
+
107
+ return stack
108
+ .split("\n")
109
+ .slice(2, 8)
110
+ .map(line => line.trim());
111
+ };
112
+
113
+ const getCctvDebugBuffer = (): CctvDebugBuffer | null => {
114
+ if (!isCctvDebugEnabled()) return null;
115
+
116
+ const existingBuffer = window.__UDS_CCTV_DEBUG__;
117
+ if (existingBuffer) return existingBuffer;
118
+
119
+ const buffer: CctvDebugBuffer = {
120
+ enabled: true,
121
+ events: [],
122
+ limit: DEBUG_BUFFER_LIMIT,
123
+ sequence: 0,
124
+ clear: () => {
125
+ buffer.events.splice(0, buffer.events.length);
126
+ buffer.sequence = 0;
127
+ },
128
+ dump: () => [...buffer.events],
129
+ };
130
+
131
+ window.__UDS_CCTV_DEBUG__ = buffer;
132
+ return buffer;
133
+ };
134
+
135
+ /**
136
+ * CCTV runtime 진단 이벤트를 ring buffer와 console에 남긴다.
137
+ * @desc
138
+ * 토큰 값은 payload에 넣지 않는다. 이 logger는 debug flag가 켜진 경우에만 동작한다.
139
+ */
140
+ export const logCctvDebugEvent = ({
141
+ event,
142
+ level = "debug",
143
+ payload,
144
+ source,
145
+ }: {
146
+ event: string;
147
+ level?: CctvDebugEventLevel;
148
+ payload?: Record<string, unknown>;
149
+ source: string;
150
+ }): void => {
151
+ const buffer = getCctvDebugBuffer();
152
+ if (!buffer) return;
153
+
154
+ const entry: CctvDebugEvent = {
155
+ at: new Date().toISOString(),
156
+ event,
157
+ level,
158
+ payload,
159
+ seq: buffer.sequence + 1,
160
+ source,
161
+ };
162
+
163
+ buffer.sequence = entry.seq;
164
+ buffer.events.push(entry);
165
+ if (buffer.events.length > buffer.limit) {
166
+ buffer.events.splice(0, buffer.events.length - buffer.limit);
167
+ }
168
+
169
+ const logger =
170
+ level === "error"
171
+ ? console.error
172
+ : level === "warn"
173
+ ? console.warn
174
+ : level === "info"
175
+ ? console.info
176
+ : console.debug;
177
+
178
+ logger.call(console, "[UDS:CCTV]", entry);
179
+ };