Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions projects/hutch/src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import { createApp } from "./server";
import { initChangelogBannerSource } from "./web/changelog-banner-source";
import type { BotDefenseEvent } from "./web/auth/auth.page";
import type { ConversionEvent } from "./conversions";
import type { SubscriptionLogEvent } from "./observability/subscription-events";
import type { AnalyticsEvent } from "@packages/web-analytics";
import { httpErrorMessageMapping } from "./web/pages/queue/queue.error";
import { initFoundingAllocation } from "./web/shared/founding-progress/founding-allocation";
Expand Down Expand Up @@ -734,6 +735,7 @@ export function createHutchApp(deps?: {
now: () => new Date(),
botDefenseLogger: HutchLogger.fromJSON<BotDefenseEvent>(),
conversionLogger: HutchLogger.fromJSON<ConversionEvent>(),
subscriptionLogger: HutchLogger.fromJSON<SubscriptionLogEvent>(),
analytics: analyticsLogger,
salt,
foundingAllocation: initFoundingAllocation({ foundingMemberLimit }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ function collectReferencedEvents(): Set<string> {
}

describe("buildAnalyticsDashboardBody — drift prevention", () => {
it("emits 28 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => {
it("emits 29 widgets (7 traffic+audience, 3 conversions, 3 imports+medium, 3 subscriptions, 2 view-funnel, 1 internal-clicks, 3 save-funnel, 1 summary-engagement, 2 audience-device, 1 errors, 1 homepage-ab, 1 blog-traffic, 1 checkout-funnel) — adding or dropping one without updating this count is a deliberate signal to review the dashboard's scope", () => {
const body = buildBody();
expect(body.widgets).toHaveLength(28);
expect(body.widgets).toHaveLength(29);
});

it("the homepage A/B widget compares arms by distinct visitors (assignment is sticky per browser, so raw counts pile a returning visitor's landings onto one arm) with raw landings alongside, grouped by variant (utm_content)", () => {
Expand Down Expand Up @@ -232,14 +232,26 @@ describe("buildAnalyticsDashboardBody — drift prevention", () => {
});

it("queries spanning subscription Lambda log groups emit one `SOURCE '<name>'` per group joined by `|` — `logGroups(namePrefix: [...])` is a CLI-only form the dashboard renderer rejects", () => {
const subscriptionQueries = widgetQueries().filter((q) => q.includes(`"${STREAMS.subscriptions}"`));
const hutchPrefix = `SOURCE '${LOG_GROUPS.hutchHandler}' | `;
const subscriptionQueries = widgetQueries().filter(
(q) => q.includes(`"${STREAMS.subscriptions}"`) && !q.startsWith(hutchPrefix),
);
const expectedSourcePrefix = `${SUBSCRIPTION_DASHBOARD_LOG_GROUPS.map((name) => `SOURCE '${name}'`).join(" | ")} | `;
for (const q of subscriptionQueries) {
expect(q.startsWith(expectedSourcePrefix)).toBe(true);
}
expect(subscriptionQueries.length).toBeGreaterThan(0);
});

it("the checkout-funnel widget sources the hutch handler log group — checkout events are emitted by the web app, not the subscription Lambdas", () => {
const q = widgetQueries().find((x) => x.includes(SUBSCRIPTION_EVENTS.checkoutStarted));
expect(q).toBeDefined();
expect(q?.startsWith(`SOURCE '${LOG_GROUPS.hutchHandler}' | `)).toBe(true);
expect(q).toContain(SUBSCRIPTION_EVENTS.checkoutCompleted);
expect(q).toContain(SUBSCRIPTION_EVENTS.checkoutReturnFailed);
expect(q).toContain("stats count(*) as checkouts by bin(1d), event");
});

it("widget positions do not overlap so every chart is visible side-by-side, not stacked", () => {
const body = buildBody();
for (let i = 0; i < body.widgets.length; i++) {
Expand Down
21 changes: 21 additions & 0 deletions projects/hutch/src/runtime/observability/analytics-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,27 @@ export function buildAnalyticsDashboardBody(deps: BuildAnalyticsDashboardDeps):
}),
);

// --- Checkout funnel ---
// These events are emitted by the hutch web app (POST /account/subscribe and
// GET /auth/checkout/success), not the subscription Lambdas, so the widget
// sources the hutch handler log group.

widgets.push(
logWidget({
region,
title: "Checkout funnel per day",
logGroupNames: [hutchLogGroupName],
query: [
"fields @timestamp, event",
`| filter stream = "${STREAMS.subscriptions}"`,
`| filter event in ["${SUBSCRIPTION_EVENTS.checkoutStarted}", "${SUBSCRIPTION_EVENTS.checkoutCompleted}", "${SUBSCRIPTION_EVENTS.checkoutReturnFailed}"]`,
"| stats count(*) as checkouts by bin(1d), event",
].join(" "),
x: 0, y: 130, width: 12, height: 8,
view: "timeSeries",
}),
);

return { widgets };
}

Expand Down
21 changes: 21 additions & 0 deletions projects/hutch/src/runtime/observability/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,29 @@ export const SUBSCRIPTION_EVENTS = {
chargeSucceeded: "charge_succeeded",
chargeFailed: "charge_failed",
cancelled: "cancelled",
checkoutStarted: "checkout_started",
checkoutCompleted: "checkout_completed",
checkoutReturnFailed: "checkout_return_failed",
} as const;

export const CHECKOUT_VARIANTS = {
trialCheckout: "trial_checkout",
cancelledResubscribe: "cancelled_resubscribe",
cardDeclineFallback: "card_decline_fallback",
} as const;

export type CheckoutVariant = (typeof CHECKOUT_VARIANTS)[keyof typeof CHECKOUT_VARIANTS];

export const CHECKOUT_RETURN_FAILURE_REASONS = {
invalidQuery: "invalid_query",
sessionNotFound: "session_not_found",
notPaid: "not_paid",
replayed: "replayed",
} as const;

export type CheckoutReturnFailureReason =
(typeof CHECKOUT_RETURN_FAILURE_REASONS)[keyof typeof CHECKOUT_RETURN_FAILURE_REASONS];

export const METRICS = {
importsCompleted: {
namespace: "Readplace/Imports",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { UserIdSchema } from "@packages/domain/user";
import type { HutchLogger } from "@packages/hutch-logger";
import { CHECKOUT_RETURN_FAILURE_REASONS, CHECKOUT_VARIANTS } from "./events";
import { initEmitSubscriptionEvent, type SubscriptionLogEvent } from "./subscription-events";

function createCapturingLogger(): {
Expand Down Expand Up @@ -84,4 +85,78 @@ describe("initEmitSubscriptionEvent", () => {
reason: "user_initiated_trial",
});
});

it("emits a checkout_started event carrying the variant and checkout session id so the funnel can attribute the click", () => {
const { logger, captured } = createCapturingLogger();
const emit = initEmitSubscriptionEvent({ logger, now: NOW });

emit.checkoutStarted({
userId: USER_ID,
variant: CHECKOUT_VARIANTS.trialCheckout,
checkoutSessionId: "cs_test_1",
});

expect(captured).toEqual([{
stream: "subscriptions",
event: "checkout_started",
timestamp: "2026-05-25T10:00:00.000Z",
user_id: USER_ID,
variant: "trial_checkout",
checkout_session_id: "cs_test_1",
}]);
});

it("emits a checkout_completed event with the subscription and checkout session ids — the missing paid-conversion signal", () => {
const { logger, captured } = createCapturingLogger();
const emit = initEmitSubscriptionEvent({ logger, now: NOW });

emit.checkoutCompleted({
userId: USER_ID,
subscriptionId: "sub_123",
checkoutSessionId: "cs_test_1",
});

expect(captured).toEqual([{
stream: "subscriptions",
event: "checkout_completed",
timestamp: "2026-05-25T10:00:00.000Z",
user_id: USER_ID,
subscription_id: "sub_123",
checkout_session_id: "cs_test_1",
}]);
});

it("emits a checkout_return_failed event with user_id and checkout_session_id when both are known", () => {
const { logger, captured } = createCapturingLogger();
const emit = initEmitSubscriptionEvent({ logger, now: NOW });

emit.checkoutReturnFailed({
reason: CHECKOUT_RETURN_FAILURE_REASONS.notPaid,
userId: USER_ID,
checkoutSessionId: "cs_test_1",
});

expect(captured).toEqual([{
stream: "subscriptions",
event: "checkout_return_failed",
timestamp: "2026-05-25T10:00:00.000Z",
reason: "not_paid",
user_id: USER_ID,
checkout_session_id: "cs_test_1",
}]);
});

it("omits user_id and checkout_session_id from checkout_return_failed when neither is known (anonymous return with no parseable session)", () => {
const { logger, captured } = createCapturingLogger();
const emit = initEmitSubscriptionEvent({ logger, now: NOW });

emit.checkoutReturnFailed({ reason: CHECKOUT_RETURN_FAILURE_REASONS.invalidQuery });

expect(captured[0]).toEqual({
stream: "subscriptions",
event: "checkout_return_failed",
timestamp: "2026-05-25T10:00:00.000Z",
reason: "invalid_query",
});
});
});
50 changes: 49 additions & 1 deletion projects/hutch/src/runtime/observability/subscription-events.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
import type { UserId } from "@packages/domain/user";
import type { HutchLogger } from "@packages/hutch-logger";
import { STREAMS, SUBSCRIPTION_EVENTS } from "./events";
import type { CheckoutReturnFailureReason, CheckoutVariant } from "./events";

export interface SubscriptionLogEvent {
stream: typeof STREAMS.subscriptions;
event: (typeof SUBSCRIPTION_EVENTS)[keyof typeof SUBSCRIPTION_EVENTS];
timestamp: string;
user_id: UserId;
user_id?: UserId;
subscription_id?: string;
reason?: string;
variant?: CheckoutVariant;
checkout_session_id?: string;
}

export interface EmitSubscriptionEvent {
chargeSucceeded: (params: { userId: UserId; subscriptionId: string }) => void;
chargeFailed: (params: { userId: UserId; reason: string }) => void;
cancelled: (params: { userId: UserId; reason: string; subscriptionId?: string }) => void;
checkoutStarted: (params: {
userId: UserId;
variant: CheckoutVariant;
checkoutSessionId: string;
}) => void;
checkoutCompleted: (params: {
userId: UserId;
subscriptionId: string;
checkoutSessionId: string;
}) => void;
checkoutReturnFailed: (params: {
reason: CheckoutReturnFailureReason;
userId?: UserId;
checkoutSessionId?: string;
}) => void;
}

export function initEmitSubscriptionEvent(deps: {
Expand Down Expand Up @@ -50,5 +68,35 @@ export function initEmitSubscriptionEvent(deps: {
...(subscriptionId ? { subscription_id: subscriptionId } : {}),
});
},
checkoutStarted: ({ userId, variant, checkoutSessionId }) => {
deps.logger.info({
stream: STREAMS.subscriptions,
event: SUBSCRIPTION_EVENTS.checkoutStarted,
timestamp: deps.now().toISOString(),
user_id: userId,
variant,
checkout_session_id: checkoutSessionId,
});
},
checkoutCompleted: ({ userId, subscriptionId, checkoutSessionId }) => {
deps.logger.info({
stream: STREAMS.subscriptions,
event: SUBSCRIPTION_EVENTS.checkoutCompleted,
timestamp: deps.now().toISOString(),
user_id: userId,
subscription_id: subscriptionId,
checkout_session_id: checkoutSessionId,
});
},
checkoutReturnFailed: ({ reason, userId, checkoutSessionId }) => {
deps.logger.info({
stream: STREAMS.subscriptions,
event: SUBSCRIPTION_EVENTS.checkoutReturnFailed,
timestamp: deps.now().toISOString(),
reason,
...(userId ? { user_id: userId } : {}),
...(checkoutSessionId ? { checkout_session_id: checkoutSessionId } : {}),
});
},
};
}
12 changes: 12 additions & 0 deletions projects/hutch/src/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ import {
import { initAuthRoutes } from "./web/auth/auth.page";
import type { BotDefenseEvent } from "./web/auth/auth.page";
import type { ConversionEvent } from "./conversions";
import {
initEmitSubscriptionEvent,
type SubscriptionLogEvent,
} from "./observability/subscription-events";
import { initGoogleAuthRoutes } from "./web/auth/google-auth.page";
import { initAppleAuthRoutes } from "./web/auth/apple-auth.page";
import { initResolveLogin } from "@packages/web-session";
Expand Down Expand Up @@ -332,6 +336,7 @@ interface AppDependencies {
stripePublishableKey: string | undefined;
botDefenseLogger: HutchLogger.Typed<BotDefenseEvent>;
conversionLogger: HutchLogger.Typed<ConversionEvent>;
subscriptionLogger: HutchLogger.Typed<SubscriptionLogEvent>;
analytics: HutchLogger.Typed<AnalyticsEvent>;
salt: string;
foundingAllocation: FoundingAllocation;
Expand Down Expand Up @@ -863,6 +868,11 @@ export function createApp(dependencies: AppDependencies): Express {

const featureToggle = new QuerystringFeatureToggle();

const emitSubscriptionEvent = initEmitSubscriptionEvent({
logger: deps.subscriptionLogger,
now: deps.now,
});

const authRouter = initAuthRoutes({
hashPassword: deps.hashPassword,
createUserWithPasswordHash,
Expand Down Expand Up @@ -896,6 +906,7 @@ export function createApp(dependencies: AppDependencies): Express {
now: deps.now,
botDefenseLogger: deps.botDefenseLogger,
conversionLogger: deps.conversionLogger,
emitSubscriptionEvent,
foundingAllocation,
buildBannerState,
consumeRateLimit: deps.consumeRateLimit,
Expand Down Expand Up @@ -1166,6 +1177,7 @@ export function createApp(dependencies: AppDependencies): Express {
}),
now: deps.now,
buildBannerState,
emitSubscriptionEvent,
});
app.use("/account", requireAuth, accountRouter);

Expand Down
18 changes: 17 additions & 1 deletion projects/hutch/src/runtime/test-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { createApp } from "./server";
import type { GetChangelogBanner } from "./web/changelog-banner-source";
import { initFoundingAllocation } from "./web/shared/founding-progress/founding-allocation";
import { type AnalyticsEvent, createAnalyticsMiddleware } from "@packages/web-analytics";
import type { SubscriptionLogEvent } from "./observability/subscription-events";

export type {
AdminBundle,
Expand Down Expand Up @@ -64,6 +65,11 @@ export interface AnalyticsBundle {
events: AnalyticsEvent[];
}

export interface SubscriptionEventsBundle {
logger: HutchLogger.Typed<SubscriptionLogEvent>;
events: SubscriptionLogEvent[];
}

export interface TestAppResult {
app: Express;
auth: AuthBundle;
Expand All @@ -84,11 +90,13 @@ export interface TestAppResult {
botDefense: BotDefenseBundle;
conversions: ConversionsBundle;
analytics: AnalyticsBundle;
subscriptionEvents: SubscriptionEventsBundle;
}

function flattenFixtureToAppDependencies(
fixture: TestAppFixture,
analyticsBundle: AnalyticsBundle,
subscriptionBundle: SubscriptionEventsBundle,
): Parameters<typeof createApp>[0] {
return {
validateSaveableUrl: fixture.shared.validateSaveableUrl,
Expand Down Expand Up @@ -212,6 +220,7 @@ function flattenFixtureToAppDependencies(
stripePublishableKey: fixture.stripePublishableKey,
botDefenseLogger: fixture.botDefense.logger,
conversionLogger: fixture.conversions.logger,
subscriptionLogger: subscriptionBundle.logger,
analytics: analyticsBundle.logger,
salt: "test-analytics-salt",
foundingAllocation: initFoundingAllocation({
Expand All @@ -238,13 +247,19 @@ export function createTestApp(
logger: { info: captureAnalytics, error: captureAnalytics, warn: captureAnalytics, debug: captureAnalytics },
events: analyticsEvents,
};
const subscriptionLogEvents: SubscriptionLogEvent[] = [];
const captureSubscription = (data: SubscriptionLogEvent) => { subscriptionLogEvents.push(data); };
const subscriptionBundle: SubscriptionEventsBundle = {
logger: { info: captureSubscription, error: captureSubscription, warn: captureSubscription, debug: captureSubscription },
events: subscriptionLogEvents,
};
const app = express()
.use(createAnalyticsMiddleware({
logger: analyticsBundle.logger,
salt: "test-analytics-salt",
now: fixture.shared.now,
}))
.use(createApp({ ...flattenFixtureToAppDependencies(fixture, analyticsBundle), ...overrides }));
.use(createApp({ ...flattenFixtureToAppDependencies(fixture, analyticsBundle, subscriptionBundle), ...overrides }));
return {
app,
auth: fixture.auth,
Expand All @@ -265,6 +280,7 @@ export function createTestApp(
botDefense: fixture.botDefense,
conversions: fixture.conversions,
analytics: analyticsBundle,
subscriptionEvents: subscriptionBundle,
};
}

Expand Down
Loading
Loading