From 515151ed8130a41f6c445b8ad829edf15bddceff Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 00:15:11 +0300 Subject: [PATCH] Add analytics facade with adapter fan-out and consent gating --- src/observability/analytics/facade.test.ts | 89 ++++++++++++++++++++++ src/observability/analytics/facade.ts | 63 +++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/observability/analytics/facade.test.ts create mode 100644 src/observability/analytics/facade.ts diff --git a/src/observability/analytics/facade.test.ts b/src/observability/analytics/facade.test.ts new file mode 100644 index 00000000..be86723a --- /dev/null +++ b/src/observability/analytics/facade.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { createAnalytics } from "./facade.js"; +import { getRecordedEvents, resetEvents } from "./sink.js"; +import type { Logger } from "@/observability/logger/types"; + +function mockLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => mockLogger()), + }; +} + +describe("createAnalytics", () => { + beforeEach(() => { + resetEvents(); + }); + + it("fans out track() to all 4 enabled adapters", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("test.event", { key: "value" }); + + const events = getRecordedEvents(); + expect(events).toHaveLength(4); + + const providers = events.map((e) => e.provider).sort(); + expect(providers).toEqual(["ctm", "dynatrace", "metrica", "variocube"]); + + for (const event of events) { + expect(event.kind).toBe("track"); + expect(event.name).toBe("test.event"); + expect(event.props).toEqual({ key: "value" }); + } + }); + + it("fans out page() to all 4 enabled adapters", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.page("/ru/online-board"); + + const events = getRecordedEvents(); + expect(events).toHaveLength(4); + + for (const event of events) { + expect(event.kind).toBe("page"); + expect(event.name).toBe("/ru/online-board"); + } + }); + + it("consent.analytics = false short-circuits before any adapter is invoked", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true }, + consent: { analytics: false, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("should.not.emit"); + analytics.page("/should/not/emit"); + + expect(getRecordedEvents()).toHaveLength(0); + }); + + it("disabled adapter is not invoked", () => { + const analytics = createAnalytics({ + enabled: { metrica: true, ctm: false, variocube: false, dynatrace: true }, + consent: { analytics: true, telemetry: true }, + logger: mockLogger(), + }); + + analytics.track("partial.event"); + + const events = getRecordedEvents(); + expect(events).toHaveLength(2); + + const providers = events.map((e) => e.provider).sort(); + expect(providers).toEqual(["dynatrace", "metrica"]); + }); +}); diff --git a/src/observability/analytics/facade.ts b/src/observability/analytics/facade.ts new file mode 100644 index 00000000..9a705996 --- /dev/null +++ b/src/observability/analytics/facade.ts @@ -0,0 +1,63 @@ +import type { Analytics, AnalyticsAdapter, AnalyticsProps, AnalyticsProviders } from "./types.js"; +import type { Logger } from "@/observability/logger/types"; +import { MetricaAdapter } from "./adapters/metrica.js"; +import { CtmAdapter } from "./adapters/ctm.js"; +import { VariocubeAdapter } from "./adapters/variocube.js"; +import { DynatraceAdapter } from "./adapters/dynatrace.js"; + +export interface CreateAnalyticsOptions { + enabled: AnalyticsProviders; + consent: { analytics: boolean; telemetry: boolean }; + logger: Logger; +} + +const NOOP_ANALYTICS: Analytics = { + track() {}, + page() {}, +}; + +/** + * Creates an Analytics instance that fans out track/page calls to enabled adapters. + * If consent.analytics is false, returns a no-op (short-circuit before any adapter). + */ +export function createAnalytics(options: CreateAnalyticsOptions): Analytics { + const { enabled, consent, logger } = options; + + if (!consent.analytics) { + logger.debug("analytics consent denied, returning no-op"); + return NOOP_ANALYTICS; + } + + const adapters: AnalyticsAdapter[] = []; + if (enabled.metrica) adapters.push(new MetricaAdapter()); + if (enabled.ctm) adapters.push(new CtmAdapter()); + if (enabled.variocube) adapters.push(new VariocubeAdapter()); + if (enabled.dynatrace) adapters.push(new DynatraceAdapter()); + + if (adapters.length === 0) { + logger.debug("no analytics adapters enabled, returning no-op"); + return NOOP_ANALYTICS; + } + + return { + track(event: string, props: AnalyticsProps = {}): void { + for (const adapter of adapters) { + try { + adapter.track(event, props); + } catch (err) { + logger.error(`analytics adapter track failed: ${adapter.name}`, { error: String(err) }); + } + } + }, + + page(url: string, props: AnalyticsProps = {}): void { + for (const adapter of adapters) { + try { + adapter.page(url, props); + } catch (err) { + logger.error(`analytics adapter page failed: ${adapter.name}`, { error: String(err) }); + } + } + }, + }; +}