Initial commit

This commit is contained in:
fraqtal
2026-07-12 08:15:46 +00:00
commit ee0fec0691
1397 changed files with 127242 additions and 0 deletions

View 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.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View 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"
}
}

View 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>;
}

View 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";

View 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);
});
});

View 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();
}
}

View File

@@ -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();
}
});
});

View 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;
}

View File

@@ -0,0 +1,5 @@
export {
AnalyticsProvider,
useAnalytics,
AnalyticsContextError,
} from "./analytics-provider";

View 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);
});
});

View 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>>;
}

View 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"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View 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") },
},
}),
);