Initial commit
This commit is contained in:
32
packages/core-analytics/AGENTS.md
Normal file
32
packages/core-analytics/AGENTS.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# @repo/core-analytics
|
||||
|
||||
Optional core package providing a vendor-neutral product analytics interface. Scaffold via `pnpm turbo gen core-package analytics`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
analytics.interface.ts # IAnalytics — track, identify, pageView, flush
|
||||
noop-analytics.ts # NoopAnalytics (default no-op implementation)
|
||||
index.ts # Barrel export
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
`IAnalytics` exposes four methods:
|
||||
|
||||
- `track(event, attributes?)` — record a named event with optional attributes
|
||||
- `identify(user)` — associate subsequent events with a user
|
||||
- `pageView(path, attributes?)` — record a page-view event
|
||||
- `flush()` — drain any in-flight queued events (returns `Promise<void>`)
|
||||
|
||||
The interface is vendor-neutral: no third-party analytics SDK is bundled. Feature
|
||||
packages depend on `IAnalytics` only; concrete implementations (e.g. a PostHog
|
||||
or Segment adapter) are wired at DI bind time in `bind-production`.
|
||||
|
||||
`NoopAnalytics` is the default implementation — all methods are no-ops and
|
||||
`flush()` resolves immediately via `Promise.resolve()`. Use it in dev-seed
|
||||
bindings and unit tests.
|
||||
|
||||
See `docs/architecture/agent-first-workflow-and-conformance.md` for the
|
||||
dependency-injection conventions.
|
||||
3
packages/core-analytics/eslint.config.js
Normal file
3
packages/core-analytics/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
39
packages/core-analytics/package.json
Normal file
39
packages/core-analytics/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@repo/core-analytics",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./react": "./src/react/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"react": "^19.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
21
packages/core-analytics/src/analytics.interface.ts
Normal file
21
packages/core-analytics/src/analytics.interface.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type AnalyticsAttributeValue = string | number | boolean;
|
||||
|
||||
export type AnalyticsUser = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface IAnalytics {
|
||||
track(
|
||||
event: string,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
identify(
|
||||
user: AnalyticsUser,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
pageView(
|
||||
path: string,
|
||||
attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
8
packages/core-analytics/src/index.ts
Normal file
8
packages/core-analytics/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
AnalyticsAttributeValue,
|
||||
AnalyticsUser,
|
||||
IAnalytics,
|
||||
} from "./analytics.interface";
|
||||
export { NoopAnalytics } from "./noop-analytics";
|
||||
export type { Analyzed } from "./with-analytics";
|
||||
export { withAnalytics } from "./with-analytics";
|
||||
54
packages/core-analytics/src/noop-analytics.test.ts
Normal file
54
packages/core-analytics/src/noop-analytics.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NoopAnalytics } from "@/noop-analytics";
|
||||
|
||||
describe("NoopAnalytics", () => {
|
||||
it("track() does not throw with event name only", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() => analytics.track("page_viewed")).not.toThrow();
|
||||
});
|
||||
|
||||
it("track() does not throw with event name and attributes", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() =>
|
||||
analytics.track("button_clicked", {
|
||||
label: "signup",
|
||||
count: 1,
|
||||
active: true,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("identify() does not throw with user only", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() => analytics.identify({ id: "user-123" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("identify() does not throw with user and attributes", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() =>
|
||||
analytics.identify({ id: "user-123" }, { plan: "pro", trial: false }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("pageView() does not throw with path only", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() => analytics.pageView("/dashboard")).not.toThrow();
|
||||
});
|
||||
|
||||
it("pageView() does not throw with path and attributes", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(() =>
|
||||
analytics.pageView("/dashboard", { referrer: "/home", duration: 120 }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("flush() resolves without throwing", async () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
await expect(analytics.flush()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("flush() returns a Promise", () => {
|
||||
const analytics = new NoopAnalytics();
|
||||
expect(analytics.flush()).toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
23
packages/core-analytics/src/noop-analytics.ts
Normal file
23
packages/core-analytics/src/noop-analytics.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
AnalyticsAttributeValue,
|
||||
AnalyticsUser,
|
||||
IAnalytics,
|
||||
} from "./analytics.interface";
|
||||
|
||||
export class NoopAnalytics implements IAnalytics {
|
||||
track(
|
||||
_event: string,
|
||||
_attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void {}
|
||||
identify(
|
||||
_user: AnalyticsUser,
|
||||
_attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void {}
|
||||
pageView(
|
||||
_path: string,
|
||||
_attributes?: Record<string, AnalyticsAttributeValue>,
|
||||
): void {}
|
||||
flush(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, renderHook } from "@testing-library/react";
|
||||
import { RecordingAnalytics } from "@repo/core-testing";
|
||||
import {
|
||||
AnalyticsContextError,
|
||||
AnalyticsProvider,
|
||||
useAnalytics,
|
||||
} from "@/react/index";
|
||||
|
||||
function Tracker() {
|
||||
const analytics = useAnalytics();
|
||||
analytics.track("test.event");
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("AnalyticsProvider", () => {
|
||||
it("makes analytics available through context and track flows through", () => {
|
||||
const recording = new RecordingAnalytics();
|
||||
|
||||
render(
|
||||
<AnalyticsProvider value={recording}>
|
||||
<Tracker />
|
||||
</AnalyticsProvider>,
|
||||
);
|
||||
|
||||
expect(recording.tracked).toContainEqual({ event: "test.event" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAnalytics", () => {
|
||||
it("throws AnalyticsContextError when called outside a provider", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
expect(() => renderHook(() => useAnalytics())).toThrow(
|
||||
AnalyticsContextError,
|
||||
);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
33
packages/core-analytics/src/react/analytics-provider.tsx
Normal file
33
packages/core-analytics/src/react/analytics-provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { IAnalytics } from "../analytics.interface";
|
||||
|
||||
const AnalyticsContext = createContext<IAnalytics | null>(null);
|
||||
|
||||
export class AnalyticsContextError extends Error {
|
||||
constructor() {
|
||||
super("useAnalytics() must be called within an <AnalyticsProvider>.");
|
||||
this.name = "AnalyticsContextError";
|
||||
}
|
||||
}
|
||||
|
||||
export function AnalyticsProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: IAnalytics;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AnalyticsContext.Provider value={value}>
|
||||
{children}
|
||||
</AnalyticsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnalytics(): IAnalytics {
|
||||
const analytics = useContext(AnalyticsContext);
|
||||
if (analytics === null) {
|
||||
throw new AnalyticsContextError();
|
||||
}
|
||||
return analytics;
|
||||
}
|
||||
5
packages/core-analytics/src/react/index.ts
Normal file
5
packages/core-analytics/src/react/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AnalyticsProvider,
|
||||
useAnalytics,
|
||||
AnalyticsContextError,
|
||||
} from "./analytics-provider";
|
||||
55
packages/core-analytics/src/with-analytics.test.ts
Normal file
55
packages/core-analytics/src/with-analytics.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, expectTypeOf } from "vitest";
|
||||
import { withAnalytics, type Analyzed } from "@/with-analytics";
|
||||
import type { IAnalytics } from "@/analytics.interface";
|
||||
import { isAnalyzed } from "@repo/core-shared/conformance";
|
||||
|
||||
function makeAnalytics(): IAnalytics {
|
||||
return {
|
||||
track: () => undefined,
|
||||
identify: () => undefined,
|
||||
pageView: () => undefined,
|
||||
flush: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("withAnalytics", () => {
|
||||
it("returns an Analyzed<F>", () => {
|
||||
const analytics = makeAnalytics();
|
||||
const fn = async (_input: { id: string }) => ({ ok: true });
|
||||
const wrapped = withAnalytics(analytics, fn);
|
||||
expectTypeOf(wrapped).toMatchTypeOf<Analyzed<typeof fn>>();
|
||||
});
|
||||
|
||||
it("attaches __analyzed as a non-enumerable property on the wrapped function", () => {
|
||||
const analytics = makeAnalytics();
|
||||
const fn = async () => ({ ok: true });
|
||||
const wrapped = withAnalytics(analytics, fn);
|
||||
expect(isAnalyzed(wrapped)).toBe(true);
|
||||
expect(Object.keys(wrapped)).not.toContain("__analyzed");
|
||||
});
|
||||
|
||||
it("does NOT pollute the original input function with the brand", () => {
|
||||
const analytics = makeAnalytics();
|
||||
const fn = async () => ({ ok: true });
|
||||
const wrapped = withAnalytics(analytics, fn);
|
||||
expect(isAnalyzed(fn)).toBe(false);
|
||||
expect(wrapped).not.toBe(fn);
|
||||
});
|
||||
|
||||
it("passes input and output through unchanged", async () => {
|
||||
const analytics = makeAnalytics();
|
||||
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
|
||||
const wrapped = withAnalytics(analytics, fn);
|
||||
const result = await wrapped({ id: "abc" });
|
||||
expect(result).toEqual({ ok: true, id: "abc" });
|
||||
});
|
||||
|
||||
it("propagates errors", async () => {
|
||||
const analytics = makeAnalytics();
|
||||
const err = new Error("boom");
|
||||
const wrapped = withAnalytics(analytics, async () => {
|
||||
throw err;
|
||||
});
|
||||
await expect(wrapped()).rejects.toBe(err);
|
||||
});
|
||||
});
|
||||
35
packages/core-analytics/src/with-analytics.ts
Normal file
35
packages/core-analytics/src/with-analytics.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IAnalytics } from "./analytics.interface";
|
||||
import { attachBrand } from "@repo/core-shared/conformance";
|
||||
|
||||
/**
|
||||
* Phantom-type brand attached at wrap time by `withAnalytics`. The conformance
|
||||
* system uses this as the type-level seam for use cases that declare
|
||||
* `analyticsEvents: [...]` in their manifest — without `__analyzed`, the
|
||||
* binding is not assignable to `ProductionUseCase<I, O, M>` when M demands it.
|
||||
* At runtime the brand is a non-enumerable property attached by `attachBrand`
|
||||
* from `@repo/core-shared/conformance`, so the boot-time assertion can verify
|
||||
* the binding went through the analytics-aware path.
|
||||
*/
|
||||
export type Analyzed<F> = F & { readonly __analyzed: true };
|
||||
|
||||
/**
|
||||
* Use-case wrapper applied at DI bind time. The wrapper is a thin closure
|
||||
* that forwards to `fn` unchanged and carries the `__analyzed` brand. The
|
||||
* forward closure (instead of returning `fn` directly) keeps the brand on
|
||||
* a fresh function so the caller's original `fn` is not mutated — important
|
||||
* when the same factory output is used elsewhere unwrapped (dev-seed paths,
|
||||
* tests).
|
||||
*/
|
||||
export function withAnalytics<Args extends unknown[], R>(
|
||||
// The wrapper attaches the brand and ensures the analytics dependency is
|
||||
// available at bind time. Actual `analytics.track()` calls live in the
|
||||
// use case body — only the use case knows which properties to extract
|
||||
// from its input/output for the analytics event.
|
||||
analytics: IAnalytics,
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
): Analyzed<(...args: Args) => Promise<R>> {
|
||||
void analytics;
|
||||
const wrapped: (...args: Args) => Promise<R> = (...args) => fn(...args);
|
||||
attachBrand(wrapped, "__analyzed");
|
||||
return wrapped as Analyzed<(...args: Args) => Promise<R>>;
|
||||
}
|
||||
12
packages/core-analytics/tsconfig.json
Normal file
12
packages/core-analytics/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/core-analytics/turbo.json
Normal file
4
packages/core-analytics/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
17
packages/core-analytics/vitest.config.ts
Normal file
17
packages/core-analytics/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(
|
||||
nodeVitestConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
|
||||
environmentMatchGlobs: [["**/*.test.tsx", "jsdom"]],
|
||||
setupFiles: ["@repo/core-testing/setup/jsdom"],
|
||||
},
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user