From 497e1b0a73b52e5c41a3aabb66d1df280c0d4f6e Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Tue, 23 Jun 2026 22:02:41 +0200 Subject: [PATCH 1/3] Add otel telemetry to next app --- app/(login)/actions.ts | 179 ++++---- app/api/team/route.ts | 6 + fault-injection-telemetry-plan.md | 663 ++++++++++++++++++++++++++++++ instrumentation.ts | 20 + lib/auth/activity.ts | 36 +- lib/auth/login.ts | 233 ++++++----- lib/auth/middleware.ts | 45 +- lib/auth/session.ts | 46 ++- lib/db/drizzle.ts | 160 ++++++- lib/db/queries.ts | 213 ++++++---- lib/payments/actions.ts | 156 ++++--- lib/telemetry.ts | 54 +++ package.json | 2 + pnpm-lock.yaml | 187 ++++++++- tests/support/faults.ts | 53 +++ 15 files changed, 1675 insertions(+), 378 deletions(-) create mode 100644 fault-injection-telemetry-plan.md create mode 100644 instrumentation.ts create mode 100644 lib/telemetry.ts diff --git a/app/(login)/actions.ts b/app/(login)/actions.ts index f22b1cf..8731b31 100644 --- a/app/(login)/actions.ts +++ b/app/(login)/actions.ts @@ -23,65 +23,70 @@ import { users, } from "@/lib/db/schema"; import { isFaultActive } from "@/lib/faults"; +import { withActionSpan, withSpan } from "@/lib/telemetry"; const signInSchema = z.object({ email: z.string().email().min(3).max(255), password: z.string().min(8).max(100), }); -export const signIn = validatedAction(signInSchema, async (data, formData) => { - const { email, password } = data; - - const userWithTeam = await db - .select({ - user: users, - team: teams, - }) - .from(users) - .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) - .leftJoin(teams, eq(teamMembers.teamId, teams.id)) - .where(eq(users.email, email)) - .limit(1); - - if (userWithTeam.length === 0) { - return { - error: "Invalid email or password. Please try again.", - email, - password, - }; - } +export const signIn = validatedAction( + signInSchema, + "sign_in", + async (data, formData) => { + const { email, password } = data; - const { user: foundUser, team: foundTeam } = userWithTeam[0]; + const userWithTeam = await db + .select({ + user: users, + team: teams, + }) + .from(users) + .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) + .leftJoin(teams, eq(teamMembers.teamId, teams.id)) + .where(eq(users.email, email)) + .limit(1); - const isPasswordValid = await comparePasswords( - password, - foundUser.passwordHash, - ); + if (userWithTeam.length === 0) { + return { + error: "Invalid email or password. Please try again.", + email, + password, + }; + } - if (!isPasswordValid) { - return { - error: "Invalid email or password. Please try again.", - email, + const { user: foundUser, team: foundTeam } = userWithTeam[0]; + + const isPasswordValid = await comparePasswords( password, - }; - } - - await Promise.all([ - setSession(foundUser), - logActivity(foundTeam?.id, foundUser.id, ActivityType.SIGN_IN), - ]); - - const redirectTo = formData.get("redirect") as string | null; - if (redirectTo === "checkout") { - const plan = formData.get("plan") as string; - const amount = formData.get("amount") as string; - redirect( - `/pricing/checkout?plan=${encodeURIComponent(plan)}&amount=${amount}`, + foundUser.passwordHash, ); - } - redirect("/dashboard"); -}); + if (!isPasswordValid) { + return { + error: "Invalid email or password. Please try again.", + email, + password, + }; + } + + await Promise.all([ + setSession(foundUser), + logActivity(foundTeam?.id, foundUser.id, ActivityType.SIGN_IN), + ]); + + const redirectTo = formData.get("redirect") as string | null; + if (redirectTo === "checkout") { + const plan = formData.get("plan") as string; + const amount = formData.get("amount") as string; + redirect( + `/pricing/checkout?plan=${encodeURIComponent(plan)}&amount=${amount}`, + ); + } + + redirect("/dashboard"); + }, +); const signUpSchema = z.object({ email: z.string().email(), @@ -89,34 +94,47 @@ const signUpSchema = z.object({ inviteId: z.string().optional(), }); -export const signUp = validatedAction(signUpSchema, async (data, formData) => { - const { email, password, inviteId } = data; +export const signUp = validatedAction( + signUpSchema, + "sign_up", + async (data, formData) => { + const { email, password, inviteId } = data; - const createdUser = await signup(email, password, inviteId); + const createdUser = await signup(email, password, inviteId); - await setSession(createdUser as NewUser); + await setSession(createdUser as NewUser); - const redirectTo = formData.get("redirect") as string | null; - if (redirectTo === "checkout") { - const plan = formData.get("plan") as string; - const amount = formData.get("amount") as string; - redirect( - `/pricing/checkout?plan=${encodeURIComponent(plan)}&amount=${amount}`, - ); - } + const redirectTo = formData.get("redirect") as string | null; + if (redirectTo === "checkout") { + const plan = formData.get("plan") as string; + const amount = formData.get("amount") as string; + redirect( + `/pricing/checkout?plan=${encodeURIComponent(plan)}&amount=${amount}`, + ); + } - redirect("/dashboard"); -}); + redirect("/dashboard"); + }, +); export async function signOut() { - const user = (await getUser()) as User; - const userWithTeam = await getUserWithTeam(user.id); - if (!(await isFaultActive("signout-activity-log-missing"))) { - await logActivity(userWithTeam?.teamId, user.id, ActivityType.SIGN_OUT); - } - if (!(await isFaultActive("signout-cookie-not-cleared"))) { - (await cookies()).delete("session"); - } + await withActionSpan("sign_out", async (span) => { + const user = (await getUser()) as User; + const userWithTeam = await getUserWithTeam(user.id); + if (!(await isFaultActive("signout-activity-log-missing"))) { + await logActivity(userWithTeam?.teamId, user.id, ActivityType.SIGN_OUT); + } + if (!(await isFaultActive("signout-cookie-not-cleared"))) { + await withSpan( + "auth.session.delete", + { "app.operation": "auth.session.delete" }, + async () => (await cookies()).delete("session"), + ); + span?.setAttribute("app.result", "session_deleted"); + } else { + span?.setAttribute("app.result", "session_delete_skipped"); + } + }); } const updatePasswordSchema = z.object({ @@ -127,6 +145,7 @@ const updatePasswordSchema = z.object({ export const updatePassword = validatedActionWithUser( updatePasswordSchema, + "update_password", async (data, _, user) => { const { currentPassword, newPassword, confirmPassword } = data; @@ -192,6 +211,7 @@ const deleteAccountSchema = z.object({ export const deleteAccount = validatedActionWithUser( deleteAccountSchema, + "delete_account", async (data, _, user) => { const { password } = data; @@ -243,6 +263,7 @@ const updateAccountSchema = z.object({ export const updateAccount = validatedActionWithUser( updateAccountSchema, + "update_account", async (data, _, user) => { const { name, email } = data; const userWithTeam = await getUserWithTeam(user.id); @@ -275,6 +296,7 @@ const removeTeamMemberSchema = z.object({ export const removeTeamMember = validatedActionWithUser( removeTeamMemberSchema, + "remove_team_member", async (data, _, user) => { const { memberId } = data; const userWithTeam = await getUserWithTeam(user.id); @@ -309,6 +331,7 @@ const inviteTeamMemberSchema = z.object({ export const inviteTeamMember = validatedActionWithUser( inviteTeamMemberSchema, + "invite_team_member", async (data, _, user) => { const { email, role } = data; const userWithTeam = await getUserWithTeam(user.id); @@ -316,17 +339,13 @@ export const inviteTeamMember = validatedActionWithUser( if (!userWithTeam?.teamId) { return { error: "User is not part of a team" }; } + const teamId = userWithTeam.teamId; const existingMember = await db .select() .from(users) .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) - .where( - and( - eq(users.email, email), - eq(teamMembers.teamId, userWithTeam.teamId), - ), - ) + .where(and(eq(users.email, email), eq(teamMembers.teamId, teamId))) .limit(1); if (existingMember.length > 0) { @@ -340,7 +359,7 @@ export const inviteTeamMember = validatedActionWithUser( .where( and( eq(invitations.email, email), - eq(invitations.teamId, userWithTeam.teamId), + eq(invitations.teamId, teamId), eq(invitations.status, "pending"), ), ) @@ -357,7 +376,7 @@ export const inviteTeamMember = validatedActionWithUser( const invitation = await db .insert(invitations) .values({ - teamId: userWithTeam.teamId, + teamId, email, role, invitedBy: user.id, @@ -365,11 +384,7 @@ export const inviteTeamMember = validatedActionWithUser( }) .returning(); - await logActivity( - userWithTeam.teamId, - user.id, - ActivityType.INVITE_TEAM_MEMBER, - ); + await logActivity(teamId, user.id, ActivityType.INVITE_TEAM_MEMBER); // TODO: Send invitation email and include ?inviteId={id} to sign-up URL // await sendInvitationEmail(email, userWithTeam.team.name, role) diff --git a/app/api/team/route.ts b/app/api/team/route.ts index 4b0fd97..eea1980 100644 --- a/app/api/team/route.ts +++ b/app/api/team/route.ts @@ -12,6 +12,12 @@ export async function GET() { }); } + if (await isFaultActive("api-team-db-read-skipped")) { + return Response.json(null, { + headers: { "x-fault-injected": "api-team-db-read-skipped" }, + }); + } + const team = await getTeamForUser(); return Response.json(team); } diff --git a/fault-injection-telemetry-plan.md b/fault-injection-telemetry-plan.md new file mode 100644 index 0000000..acee994 --- /dev/null +++ b/fault-injection-telemetry-plan.md @@ -0,0 +1,663 @@ +# Fault Injection Telemetry Plan + +This plan defines the next fault-injection work for this Playwright tutorial repository. The goal is not to implement invariant discovery yet. The goal is to build a stronger suite of normal product tests plus opt-in faults that can produce differentiated Playwright, browser, network, server, and data-layer telemetry. + +## Goal + +Create deterministic failing Playwright runs that are useful for testing generic invariant discovery over test traces. + +The fault suite should produce failures where the final Playwright symptom is often late, while the earlier divergence is visible in trace data such as: + +- browser page errors +- console messages +- resource loads +- network requests and responses +- redirects +- server actions +- API routes +- database reads and writes +- authentication/session behavior +- product state transitions + +## Non-Goals + +- Do not implement invariant discovery in this repository yet. +- Do not create a separate fault-only test suite. +- Do not make normal PR or main test runs require special flags. +- Support one named fault per run; single-fault runs are sufficient. +- Do not add many status-code variants unless they create meaningfully different telemetry. +- Do not add flaky or seeded faults until deterministic examples are working well. + +## Activation Model + +Faults are activated one at a time: + +```bash +pnpm playwright test +FAULTS=script-chunk-404 pnpm playwright test tests/change-password.spec.ts +FAULTS=payment-subscription-update-skipped pnpm playwright test tests/plan-upgrade.spec.ts +``` + +`FAULTS` supports: + +- unset or empty: no active fault; the normal suite should pass. +- a single fault name: activate that named fault only. + +The implementation should optimize for targeted single-fault runs. + +## Test Structure Rules + +Tests that participate in fault injection should continue importing the local fixture wrapper: + +```ts +import { expect, test } from "./test"; +``` + +Tests that need a fresh user should continue using `testWithNewUser`, which extends the same base fixture. + +The test bodies should remain product journeys. Fault behavior belongs in: + +```text +tests/test.ts +tests/support/faults.ts +lib/faults.ts +app routes / server actions only when server-side behavior is required +``` + +Every participating test must pass normally when `FAULTS` is unset. + +## Fault Design Principles + +- One fault name should represent one clear abnormal behavior. +- Faults should be deterministic unless the fault name explicitly describes a flaky scenario. +- Prefer real product journeys over synthetic pages. +- Prefer Playwright-layer faults when they produce enough telemetry. +- Add app-level fault gates only when the divergence must occur in server-side code or data state. +- The earliest abnormal event should usually be different from the final Playwright assertion failure. +- Fault names should describe the behavior, not the implementation mechanism. +- Avoid stacking multiple faults on one test target. + +## Invariant Coverage Targets + +The fault suite should intentionally cover these generic invariant shapes: + +| Invariant Shape | Desired Fault Examples | +| --- | --- | +| presence | expected request, log, DB write, resource, or UI event is missing | +| count range | duplicate payment/member/activity/retry, missing row, extra row | +| numeric range | latency spike, timeout, unusually long server action | +| attribute distribution | status code, role, amount, plan, action type, redirect target changes | +| edge existence | action succeeds but related DB write, redirect, refetch, or log is missing | +| edge count range | one action creates too many related rows or requests | +| relative order | activity entries or request/action sequence appears in wrong order | +| conditional eventuality | after trigger A, expected B does not appear within the learned window | + +## Fault Categories + +Use both product-level and infrastructure-level faults. The invariant system should eventually learn across both without needing domain-specific rules. + +### Infrastructure Faults + +Infrastructure faults simulate conditions around the app rather than business logic defects. + +Useful telemetry surfaces: + +- resource request failures +- hydration failures +- browser exceptions +- network latency +- auth middleware redirects +- session cookie mutation +- server-render latency +- API contract breakage + +### Product/Data Faults + +Product faults simulate incorrect application behavior or inconsistent side effects. + +Useful telemetry surfaces: + +- server action completion +- DB rows inserted, skipped, duplicated, or updated incorrectly +- activity log side effects +- payment side effects +- invitation/member relationships +- user/session state transitions + +## Existing Faults + +| Fault | Keep? | Notes | +| --- | --- | --- | +| `api-team-500` | Yes | Good simple HTTP status and API response-distribution example. | +| `api-team-malformed-json` | Maybe | Useful parser/client contract example, but overlaps with API failure unless page errors are captured. | +| `script-404` | Replace or refine | Prefer `script-chunk-404` targeted at a deterministic app script/chunk. | +| `unexpected-dashboard-redirect` | Replace | Prefer auth/session faults that produce the redirect through real middleware behavior. | +| `activity-missing-create-team` | Yes | Good presence/count failure on server-rendered product data. | +| `payment-server-error` | Yes | Good backend exception example, but less valuable than partial side-effect faults. | +| `invite-accepted-but-member-missing` | Yes | Strong edge-existence example: invite acceptance without membership relation. | + +## Priority Fault Backlog + +### 1. `script-chunk-404` + +Target test: `tests/change-password.spec.ts` or `tests/plan-upgrade.spec.ts` + +Layer: infrastructure/frontend resource + +Method: + +- In `tests/support/faults.ts`, intercept one deterministic script request. +- Return HTTP 404 with `x-fault-injected: script-chunk-404`. +- Avoid intercepting every script if possible, because broad interception can obscure the useful failure point. + +Expected telemetry: + +- resource response with status 404 +- possible hydration failure +- possible console/page error +- later interaction failure or timeout + +Expected final failure: + +- button/link interaction cannot complete, or final URL/UI assertion times out. + +Invariant coverage: + +- presence +- attribute distribution +- conditional eventuality + +### 2. `script-chunk-timeout` + +Target test: `tests/activity-section.spec.ts` or `tests/plan-upgrade.spec.ts` + +Layer: infrastructure/frontend resource timing + +Method: + +- Intercept one deterministic script request. +- Delay long enough that hydration or interaction-dependent behavior exceeds the relevant Playwright expectation timeout. +- Prefer a bounded delay over an indefinitely hanging route so the test remains debuggable. + +Expected telemetry: + +- script request duration outside normal numeric range +- delayed or absent hydration-dependent actions +- final selector or navigation timeout + +Expected final failure: + +- navigation click, form submission, or hydrated control interaction fails. + +Invariant coverage: + +- numeric range +- conditional eventuality +- order + +### 3. `runtime-error-after-hydration` + +Target test: any dashboard journey, preferably `tests/activity-section.spec.ts` + +Layer: infrastructure/browser runtime + +Method: + +- Inject a browser-side script after initial page load that throws an error. +- The test should still proceed until a normal product assertion fails. +- Do not add hidden fixture assertions for the page error. + +Expected telemetry: + +- `pageerror` event +- console/error event +- later product assertion failure + +Expected final failure: + +- a normal UI assertion fails after the page error. + +Invariant coverage: + +- presence +- order +- conditional eventuality + +### 4. `api-team-latency-spike` + +Target test: `tests/change-name.spec.ts` + +Layer: infrastructure/network timing + +Method: + +- Intercept `**/api/team`. +- Delay before continuing or fulfilling. +- Keep status/body otherwise normal if possible. + +Expected telemetry: + +- `/api/team` duration outside learned range +- normal account-update success occurs earlier +- team-member UI never refreshes before timeout + +Expected final failure: + +- `John Doe` team-member assertion times out. + +Invariant coverage: + +- numeric range +- conditional eventuality + +### 5. `api-user-malformed-json` + +Target test: `tests/change-email.spec.ts` + +Layer: infrastructure/API contract + +Method: + +- Intercept or app-gate `/api/user` to return HTTP 200 with invalid JSON. +- Use this where `/api/user` feeds visible user state, such as the general settings form or user menu. + +Expected telemetry: + +- successful HTTP status with invalid response body +- client parse exception +- missing user-dependent UI state + +Expected final failure: + +- expected email input value or authenticated UI assertion fails. + +Invariant coverage: + +- attribute distribution +- presence +- conditional eventuality + +### 6. `session-cookie-invalid-on-dashboard` + +Target test: `tests/activity-section.spec.ts` + +Layer: infrastructure/auth/session + +Method: + +- Before visiting `/dashboard`, overwrite the `session` cookie with an invalid value. +- Let `proxy.ts` and normal auth/session code handle the result. + +Expected telemetry: + +- invalid JWT verification path +- session cookie deletion or replacement +- redirect to `/sign-in` +- missing dashboard route/page events + +Expected final failure: + +- dashboard URL or `Team Settings` heading assertion fails. + +Invariant coverage: + +- attribute distribution +- edge existence +- conditional eventuality + +### 7. `session-cookie-missing-mid-flow` + +Target test: `tests/plan-upgrade.spec.ts` or `tests/change-password.spec.ts` + +Layer: infrastructure/auth/session + +Method: + +- Allow the initial authenticated page to load. +- Clear the `session` cookie before an authenticated server action. +- The product action should fail through normal auth behavior. + +Expected telemetry: + +- initial authenticated route succeeds +- later server action/API has unauthenticated behavior +- redirect, thrown action error, or missing success side effect + +Expected final failure: + +- payment success, password success, or dashboard assertion fails. + +Invariant coverage: + +- order +- conditional eventuality +- edge existence + +### 8. `account-update-db-write-skipped` + +Target test: `tests/change-name.spec.ts` + +Layer: product/data side effect + +Method: + +- Add an app-level fault gate in `updateAccount`. +- Return the normal success state and still log activity if desired. +- Skip the `users` row update. + +Expected telemetry: + +- server action completes successfully +- activity log may exist +- `/api/team` and `/api/user` still expose old user data + +Expected final failure: + +- `John Doe` does not appear in the team member list. + +Invariant coverage: + +- edge existence +- conditional eventuality + +### 9. `password-hash-update-skipped` + +Target test: `tests/change-password.spec.ts` + +Layer: product/data side effect + +Method: + +- Add an app-level fault gate in `updatePassword`. +- Return `Password updated successfully.` but skip updating `users.passwordHash`. +- Optionally still write the `UPDATE_PASSWORD` activity log to create a stronger partial-side-effect trace. + +Expected telemetry: + +- update action returns success +- password hash DB update missing +- sign-out succeeds +- sign-in with new password fails + +Expected final failure: + +- final dashboard URL or heading assertion after re-login fails. + +Invariant coverage: + +- edge existence +- conditional eventuality + +### 10. `payment-subscription-update-skipped` + +Target test: `tests/plan-upgrade.spec.ts` + +Layer: product/data side effect + +Method: + +- In `processPayment`, insert the payment row. +- Skip the `teams` subscription update. +- Still redirect to `/dashboard?payment=success`. + +Expected telemetry: + +- payment insert exists +- subscription update missing +- success redirect occurs +- dashboard refetch shows Free plan + +Expected final failure: + +- dashboard still shows `Current Plan: Free` or lacks `Billed monthly`. + +Invariant coverage: + +- edge existence +- attribute distribution +- conditional eventuality + +### 11. `invite-role-drift` + +Target test: `tests/team-invitation.spec.ts` + +Layer: product/data attribute drift + +Method: + +- During invited signup, insert the team member with a role different from the invitation role. +- Keep signup and membership creation otherwise successful. + +Expected telemetry: + +- invitation accepted +- team member relationship exists +- role attribute differs from normal distribution + +Expected final failure: + +- expected `member` role assertion fails. + +Invariant coverage: + +- attribute distribution +- edge existence + +## New Normal Tests To Add + +These tests should be added as normal passing product journeys before attaching faults. + +### `tests/activity-after-account-update.spec.ts` + +Normal journey: + +- create fresh user +- navigate to general settings +- update account name +- navigate to activity page +- assert `You updated your account` appears + +Faults enabled later: + +- `activity-update-log-missing` +- `activity-update-log-mislabelled` + +Why this test matters: + +- Creates a clean action-to-activity conditional eventuality. +- Gives the invariant miner an expected server action to DB activity edge. + +### `tests/activity-order.spec.ts` + +Normal journey: + +- create fresh user +- perform two visible actions, such as account update then password update +- navigate to activity page +- assert the newest expected activity appears before the older expected activity + +Faults enabled later: + +- `activity-order-inverted` +- `activity-timestamp-drift` + +Why this test matters: + +- Produces relative-order coverage. +- Produces timestamp/order telemetry without relying only on final selector absence. + +### `tests/payment-history.spec.ts` + +Normal journey: + +- create fresh user +- complete Plus checkout +- call authenticated `/api/payment` from the page context or add UI if needed +- assert exactly one payment row for the current team with amount `1200`, currency `USD`, and plan `Plus` + +Faults enabled later: + +- `payment-duplicate-charge` +- `payment-wrong-amount` +- `payment-row-missing` + +Why this test matters: + +- Produces count-range and attribute-distribution coverage. +- Differentiates successful redirect from correct persisted payment state. + +### `tests/signout-session.spec.ts` + +Normal journey: + +- create fresh user +- visit dashboard +- sign out from the user menu +- assert signed-out home/header state +- navigate directly to `/dashboard` +- assert redirect to `/sign-in` + +Faults enabled later: + +- `signout-cookie-not-cleared` +- `signout-activity-log-missing` + +Why this test matters: + +- Produces auth/session and redirect telemetry through a normal product journey. + +### `tests/duplicate-invite.spec.ts` + +Normal journey: + +- create fresh user +- invite an email +- invite the same email again +- assert duplicate invitation is rejected with the existing product error + +Faults enabled later: + +- `duplicate-pending-invite-allowed` + +Why this test matters: + +- Produces uniqueness/count coverage around invitations. + +## Second-Wave Faults + +Implement after the priority backlog and new normal tests are stable. + +| Fault | Test | Layer | Expected Divergence | +| --- | --- | --- | --- | +| `activity-update-log-missing` | `activity-after-account-update.spec.ts` | product/data | account update succeeds but `UPDATE_ACCOUNT` activity row is missing | +| `activity-update-log-mislabelled` | `activity-after-account-update.spec.ts` | product/data | activity row exists with wrong action attribute | +| `activity-order-inverted` | `activity-order.spec.ts` | product/data | activity timestamps/order are reversed | +| `activity-timestamp-drift` | `activity-order.spec.ts` | product/data | one activity timestamp is outside normal relative timing | +| `payment-duplicate-charge` | `payment-history.spec.ts` | product/data | one checkout creates two payment rows | +| `payment-wrong-amount` | `payment-history.spec.ts` | product/data | payment row has unexpected amount or plan | +| `payment-row-missing` | `payment-history.spec.ts` | product/data | subscription update succeeds but payment row is absent | +| `signout-cookie-not-cleared` | `signout-session.spec.ts` | product/session | sign-out action runs but session remains valid | +| `signout-activity-log-missing` | `signout-session.spec.ts` | product/data | sign-out succeeds but activity log is missing | +| `duplicate-pending-invite-allowed` | `duplicate-invite.spec.ts` | product/data | second identical pending invitation is created | + +## Implementation Steps + +### Step 1: Simplify Fault Activation Semantics + +- Keep `FAULTS` as a single fault-name string. +- Do not add multi-fault activation unless a concrete need appears. +- Update `tests/support/faults.ts` so each implemented fault only activates for its intended test title. +- Keep app-level `isFaultActive(faultName)` as exact single-name matching unless there is a concrete need to change it. + +### Step 2: Refine Existing Infrastructure Faults + +- Replace broad `script-404` behavior with targeted `script-chunk-404`. +- Add `script-chunk-timeout`. +- Add `runtime-error-after-hydration`. +- Verify these produce resource/page-error telemetry before the final assertion failure. + +### Step 3: Add Auth/Session Infrastructure Faults + +- Add `session-cookie-invalid-on-dashboard` in the Playwright fixture layer. +- Add `session-cookie-missing-mid-flow` with targeted installation for one journey. +- Prefer real middleware/auth behavior over synthetic redirect fulfillment. +- Replace or de-emphasize `unexpected-dashboard-redirect`. + +### Step 4: Add Product/Data Side-Effect Faults To Existing Tests + +- Add `account-update-db-write-skipped` in `updateAccount`. +- Add `password-hash-update-skipped` in `updatePassword`. +- Add `payment-subscription-update-skipped` in `processPayment`. +- Add `invite-role-drift` in invitation signup/member insertion. + +### Step 5: Add New Normal Product Tests + +- Add `activity-after-account-update.spec.ts`. +- Add `activity-order.spec.ts` if ordering can be asserted reliably. +- Add `payment-history.spec.ts` if payment rows can be queried in a test-scoped way. +- Add `signout-session.spec.ts`. +- Add `duplicate-invite.spec.ts`. + +Each test should pass without faults before any fault is attached. + +### Step 6: Add Second-Wave Faults To New Tests + +- Add activity log missing/mislabelled/order/timestamp faults. +- Add payment duplicate/wrong-amount/missing-row faults. +- Add sign-out cookie/activity faults. +- Add duplicate-invite acceptance fault. + +### Step 7: Verification Commands + +Normal suite: + +```bash +pnpm playwright test +``` + +Representative single-fault runs: + +```bash +FAULTS=script-chunk-404 pnpm playwright test tests/change-password.spec.ts +FAULTS=session-cookie-invalid-on-dashboard pnpm playwright test tests/activity-section.spec.ts +FAULTS=account-update-db-write-skipped pnpm playwright test tests/change-name.spec.ts +FAULTS=password-hash-update-skipped pnpm playwright test tests/change-password.spec.ts +FAULTS=payment-subscription-update-skipped pnpm playwright test tests/plan-upgrade.spec.ts +FAULTS=invite-role-drift pnpm playwright test tests/team-invitation.spec.ts +``` + +Later new-test fault runs: + +```bash +FAULTS=activity-update-log-missing pnpm playwright test tests/activity-after-account-update.spec.ts +FAULTS=activity-order-inverted pnpm playwright test tests/activity-order.spec.ts +FAULTS=payment-duplicate-charge pnpm playwright test tests/payment-history.spec.ts +FAULTS=signout-cookie-not-cleared pnpm playwright test tests/signout-session.spec.ts +FAULTS=duplicate-pending-invite-allowed pnpm playwright test tests/duplicate-invite.spec.ts +``` + +## Success Criteria + +- `pnpm playwright test` passes with `FAULTS` unset. +- Each priority fault can be run by name and fails deterministically. +- Each fault has a distinct earliest abnormal telemetry event. +- The final Playwright failure remains a normal product assertion, not a hidden fixture assertion. +- The suite covers infrastructure failures, product data failures, and partial side-effect failures. +- The suite provides examples for presence, count range, numeric range, attribute distribution, edge existence, edge count range, order, and conditional eventuality invariants. + +## Initial Implementation Order + +Recommended order: + +1. `script-chunk-404` +2. `api-team-latency-spike` +3. `session-cookie-invalid-on-dashboard` +4. `account-update-db-write-skipped` +5. `payment-subscription-update-skipped` +6. `password-hash-update-skipped` +7. `invite-role-drift` +8. `activity-after-account-update.spec.ts` +9. `payment-history.spec.ts` +10. second-wave activity/payment/signout faults + +This order gets differentiated infrastructure and product failures quickly while avoiding new test creation until the existing fault harness has proven stable. diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..25233de --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,20 @@ +function backendOtelEnabled() { + return Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT); +} + +export async function register() { + if (!backendOtelEnabled()) { + return; + } + + const { registerOTel } = await import("@vercel/otel"); + + registerOTel({ + serviceName: process.env.OTEL_SERVICE_NAME || "playwright-tutorial-next", + traceSampler: "always_on", + attributes: { + "service.namespace": "playwright-tutorial", + "endform.telemetry.source": "next-app", + }, + }); +} diff --git a/lib/auth/activity.ts b/lib/auth/activity.ts index cb7d638..648d731 100644 --- a/lib/auth/activity.ts +++ b/lib/auth/activity.ts @@ -4,6 +4,7 @@ import { activityLogs, type NewActivityLog, } from "@/lib/db/schema"; +import { withSpan } from "@/lib/telemetry"; export async function logActivity( teamId: number | null | undefined, @@ -11,14 +12,29 @@ export async function logActivity( type: ActivityType, ipAddress?: string, ) { - if (teamId === null || teamId === undefined) { - return; - } - const newActivity: NewActivityLog = { - teamId, - userId, - action: type, - ipAddress: ipAddress || "", - }; - await db.insert(activityLogs).values(newActivity); + await withSpan( + "activity.log", + { + "app.operation": "activity.log", + "activity.type": type, + }, + async (span) => { + if (teamId === null || teamId === undefined) { + span?.setAttribute("app.result", "team_missing"); + + return; + } + + const newActivity: NewActivityLog = { + teamId, + userId, + action: type, + ipAddress: ipAddress || "", + }; + + await db.insert(activityLogs).values(newActivity); + + span?.setAttribute("app.result", "activity_created"); + }, + ); } diff --git a/lib/auth/login.ts b/lib/auth/login.ts index 02a7c1a..a632ac0 100644 --- a/lib/auth/login.ts +++ b/lib/auth/login.ts @@ -12,6 +12,7 @@ import { users, } from "@/lib/db/schema"; import { isFaultActive } from "@/lib/faults"; +import { withSpan } from "@/lib/telemetry"; import { logActivity } from "./activity"; export async function signup( @@ -19,115 +20,141 @@ export async function signup( password: string, inviteId?: string, ) { - const existingUser = await db - .select() - .from(users) - .where(eq(users.email, email)) - .limit(1); - - if (existingUser.length > 0) { - return { - error: "Failed to create user. Please try again.", - email, - password, - }; - } - - const passwordHash = await hashPassword(password); - - const newUser: NewUser = { - email, - passwordHash, - role: "owner", // Default role, will be overridden if there's an invitation - }; - - const [createdUser] = await db.insert(users).values(newUser).returning(); - - if (!createdUser) { - return { - error: "Failed to create user. Please try again.", - email, - password, - }; - } - - let teamId: number; - let userRole: string; - let createdTeam: typeof teams.$inferSelect | null = null; - - if (inviteId) { - // Check if there's a valid invitation - const [invitation] = await db - .select() - .from(invitations) - .where( - and( - eq(invitations.id, parseInt(inviteId, 10)), - eq(invitations.email, email), - eq(invitations.status, "pending"), - ), - ) - .limit(1); - - if (invitation) { - teamId = invitation.teamId; - userRole = (await isFaultActive("invite-role-drift")) - ? "owner" - : invitation.role; - - await db - .update(invitations) - .set({ status: "accepted" }) - .where(eq(invitations.id, invitation.id)); - - await logActivity(teamId, createdUser.id, ActivityType.ACCEPT_INVITATION); - - [createdTeam] = await db + return withSpan( + "auth.signup", + { + "app.operation": "auth.signup", + "auth.invitation_present": Boolean(inviteId), + }, + async (span) => { + const existingUser = await db .select() - .from(teams) - .where(eq(teams.id, teamId)) + .from(users) + .where(eq(users.email, email)) .limit(1); - } else { - return { error: "Invalid or expired invitation.", email, password }; - } - } else { - // Create a new team if there's no invitation - const newTeam: NewTeam = { - name: `${email}'s Team`, - }; - - [createdTeam] = await db.insert(teams).values(newTeam).returning(); - - if (!createdTeam) { - return { - error: "Failed to create team. Please try again.", - email, - password, - }; - } - teamId = createdTeam.id; - userRole = "owner"; + if (existingUser.length > 0) { + span?.setAttribute("app.result", "user_exists"); - await logActivity(teamId, createdUser.id, ActivityType.CREATE_TEAM); - } + return { + error: "Failed to create user. Please try again.", + email, + password, + }; + } - const newTeamMember: NewTeamMember = { - userId: createdUser.id, - teamId: teamId, - role: userRole, - }; + const passwordHash = await hashPassword(password); - const skipTeamMembership = - Boolean(inviteId) && - (await isFaultActive("invite-accepted-but-member-missing")); + const newUser: NewUser = { + email, + passwordHash, + role: "owner", // Default role, will be overridden if there's an invitation + }; - await Promise.all([ - ...(skipTeamMembership - ? [] - : [db.insert(teamMembers).values(newTeamMember)]), - logActivity(teamId, createdUser.id, ActivityType.SIGN_UP), - ]); + const [createdUser] = await db.insert(users).values(newUser).returning(); + + if (!createdUser) { + span?.setAttribute("app.result", "user_create_failed"); + + return { + error: "Failed to create user. Please try again.", + email, + password, + }; + } + + let teamId: number; + let userRole: string; + let createdTeam: typeof teams.$inferSelect | null = null; + + if (inviteId) { + // Check if there's a valid invitation + const [invitation] = await db + .select() + .from(invitations) + .where( + and( + eq(invitations.id, parseInt(inviteId, 10)), + eq(invitations.email, email), + eq(invitations.status, "pending"), + ), + ) + .limit(1); + + if (invitation) { + teamId = invitation.teamId; + userRole = (await isFaultActive("invite-role-drift")) + ? "owner" + : invitation.role; + + await db + .update(invitations) + .set({ status: "accepted" }) + .where(eq(invitations.id, invitation.id)); + + await logActivity( + teamId, + createdUser.id, + ActivityType.ACCEPT_INVITATION, + ); + + [createdTeam] = await db + .select() + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + } else { + span?.setAttribute("app.result", "invitation_invalid"); + + return { error: "Invalid or expired invitation.", email, password }; + } + } else { + // Create a new team if there's no invitation + const newTeam: NewTeam = { + name: `${email}'s Team`, + }; + + [createdTeam] = await db.insert(teams).values(newTeam).returning(); + + if (!createdTeam) { + span?.setAttribute("app.result", "team_create_failed"); + + return { + error: "Failed to create team. Please try again.", + email, + password, + }; + } + + teamId = createdTeam.id; + userRole = "owner"; + + await logActivity(teamId, createdUser.id, ActivityType.CREATE_TEAM); + } + + const newTeamMember: NewTeamMember = { + userId: createdUser.id, + teamId: teamId, + role: userRole, + }; - return createdUser as NewUser; + const skipTeamMembership = + Boolean(inviteId) && + (await isFaultActive("invite-accepted-but-member-missing")); + + await Promise.all([ + ...(skipTeamMembership + ? [] + : [db.insert(teamMembers).values(newTeamMember)]), + logActivity(teamId, createdUser.id, ActivityType.SIGN_UP), + ]); + + span?.setAttribute( + "app.result", + skipTeamMembership ? "team_membership_skipped" : "user_created", + ); + + return createdUser as NewUser; + }, + ); } diff --git a/lib/auth/middleware.ts b/lib/auth/middleware.ts index 203f2ec..828666d 100644 --- a/lib/auth/middleware.ts +++ b/lib/auth/middleware.ts @@ -2,54 +2,61 @@ import { redirect } from "next/navigation"; import type { z } from "zod"; import { getTeamForUser, getUser } from "@/lib/db/queries"; import type { TeamDataWithMembers, User } from "@/lib/db/schema"; +import { withActionSpan } from "@/lib/telemetry"; export type ActionState = { error?: string; success?: string; - [key: string]: any; // This allows for additional properties + [key: string]: string | number | readonly string[] | undefined; }; -type ValidatedActionFunction, T> = ( +type ValidatedActionFunction = ( data: z.infer, formData: FormData, ) => Promise; -export function validatedAction, T>( +export function validatedAction( schema: S, + operation: string, action: ValidatedActionFunction, ) { return async (_prevState: ActionState, formData: FormData) => { - const result = schema.safeParse(Object.fromEntries(formData)); - if (!result.success) { - return { error: result.error.issues[0].message }; - } + return withActionSpan(operation, async () => { + const result = schema.safeParse(Object.fromEntries(formData)); + if (!result.success) { + return { error: result.error.issues[0].message }; + } - return action(result.data, formData); + return action(result.data, formData); + }); }; } -type ValidatedActionWithUserFunction, T> = ( +type ValidatedActionWithUserFunction = ( data: z.infer, formData: FormData, user: User, ) => Promise; -export function validatedActionWithUser, T>( +export function validatedActionWithUser( schema: S, + operation: string, action: ValidatedActionWithUserFunction, ) { return async (_prevState: ActionState, formData: FormData) => { - const user = await getUser(); - if (!user) { - throw new Error("User is not authenticated"); - } + return withActionSpan(operation, async () => { + const user = await getUser(); + if (!user) { + throw new Error("User is not authenticated"); + } - const result = schema.safeParse(Object.fromEntries(formData)); - if (!result.success) { - return { error: result.error.issues[0].message }; - } + const result = schema.safeParse(Object.fromEntries(formData)); + if (!result.success) { + return { error: result.error.issues[0].message }; + } - return action(result.data, formData, user); + return action(result.data, formData, user); + }); }; } diff --git a/lib/auth/session.ts b/lib/auth/session.ts index a61fef3..c58fd00 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -2,6 +2,7 @@ import { compare, hash } from "bcryptjs"; import { jwtVerify, SignJWT } from "jose"; import { cookies } from "next/headers"; import type { NewUser } from "@/lib/db/schema"; +import { withSpan } from "@/lib/telemetry"; const DEMO_AUTH_SECRET = "playwright-tutorial-demo-auth-secret"; const key = new TextEncoder().encode( @@ -47,24 +48,37 @@ export async function getSession() { } export async function createSession(userId: number, expires: Date) { - const session: SessionData = { - user: { id: userId }, - expires: expires.toISOString(), - }; - return await signToken(session); + return withSpan( + "auth.session.create", + { "app.operation": "auth.session.create" }, + async () => { + const session: SessionData = { + user: { id: userId }, + expires: expires.toISOString(), + }; + + return await signToken(session); + }, + ); } export async function setSession(user: NewUser) { - if (user.id == null) { - throw new Error("Cannot create a session for a user without an id"); - } + await withSpan( + "auth.session.set_cookie", + { "app.operation": "auth.session.set_cookie" }, + async () => { + if (user.id == null) { + throw new Error("Cannot create a session for a user without an id"); + } - const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); - const encryptedSession = await createSession(user.id, expiresInOneDay); - (await cookies()).set("session", encryptedSession, { - expires: expiresInOneDay, - httpOnly: true, - secure: true, - sameSite: "lax", - }); + const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); + const encryptedSession = await createSession(user.id, expiresInOneDay); + (await cookies()).set("session", encryptedSession, { + expires: expiresInOneDay, + httpOnly: true, + secure: true, + sameSite: "lax", + }); + }, + ); } diff --git a/lib/db/drizzle.ts b/lib/db/drizzle.ts index e7f940d..62fb090 100644 --- a/lib/db/drizzle.ts +++ b/lib/db/drizzle.ts @@ -6,6 +6,7 @@ import { type AsyncRemoteCallback, drizzle as drizzleProxy, } from "drizzle-orm/sqlite-proxy"; +import { withSpan } from "../telemetry"; import { resolveRuntimeDatabaseConfig } from "./config"; import * as schema from "./schema"; @@ -14,6 +15,11 @@ dotenv.config(); type QueryMethod = Parameters[2]; type BatchQuery = Parameters[0][number]; type ProxyQueryResult = Awaited>; +type LibsqlClient = ReturnType; +type InstrumentableLibsqlClient = { + execute: (...args: unknown[]) => Promise; + batch: (...args: unknown[]) => Promise; +}; const databaseConfig = resolveRuntimeDatabaseConfig(); @@ -25,6 +31,16 @@ async function executeProxyQuery( sql: string, params: unknown[], method: QueryMethod, +): Promise { + return withDbSpan(sql, method, () => + executeProxyQueryUntraced(sql, params, method), + ); +} + +async function executeProxyQueryUntraced( + sql: string, + params: unknown[], + method: QueryMethod, ): Promise { const proxyUrl = databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined; @@ -51,6 +67,38 @@ async function executeProxyQuery( async function executeProxyBatch( queries: BatchQuery[], +): Promise { + return withSpan( + "db.batch", + { + "db.system": "sqlite", + "db.operation": "batch", + "db.statement_count": queries.length, + }, + async (span) => { + const operations = [ + ...new Set(queries.map((query) => sqlOperation(query.sql))), + ]; + const collections = [ + ...new Set( + queries.map((query) => sqlCollection(query.sql)).filter(Boolean), + ), + ]; + + if (operations.length === 1 && operations[0]) { + span?.setAttribute("db.operation", operations[0]); + } + if (collections.length === 1 && collections[0]) { + span?.setAttribute("db.collection", collections[0]); + } + + return executeProxyBatchUntraced(queries); + }, + ); +} + +async function executeProxyBatchUntraced( + queries: BatchQuery[], ): Promise { const proxyUrl = databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined; @@ -84,6 +132,116 @@ function ensureTrailingSlash(url: string) { return url.endsWith("/") ? url : `${url}/`; } +function instrumentLibsqlClient(client: LibsqlClient): LibsqlClient { + const instrumented = client as unknown as InstrumentableLibsqlClient; + const execute = instrumented.execute.bind(client); + const batch = instrumented.batch.bind(client); + + instrumented.execute = (...args: unknown[]) => { + const sql = sqlFromStatement(args[0]); + return withDbSpan(sql, "execute", () => execute(...args)); + }; + + instrumented.batch = (...args: unknown[]) => { + const statements = Array.isArray(args[0]) ? args[0] : []; + + return withSpan( + "db.batch", + { + "db.system": "sqlite", + "db.operation": "batch", + "db.statement_count": statements.length, + }, + async () => batch(...args), + ); + }; + + return client; +} + +function withDbSpan( + sql: string | undefined, + method: string, + fn: () => Promise, +) { + const operation = sqlOperation(sql) ?? method; + const collection = sqlCollection(sql); + + return withSpan( + "db.query", + { + "db.system": "sqlite", + "db.operation": operation, + ...(collection ? { "db.collection": collection } : {}), + }, + async (span) => { + if (await shouldInjectDbLatencySpike(operation, collection)) { + span?.setAttribute("app.result", "latency_spike"); + await sleep(5000); + } + + return fn(); + }, + ); +} + +async function shouldInjectDbLatencySpike( + operation: string, + collection: string | undefined, +) { + return ( + operation === "select" && + collection === "team_members" && + (await isRequestFaultActive("api-team-db-latency-spike")) + ); +} + +async function isRequestFaultActive(faultName: string) { + try { + const { headers } = await import("next/headers"); + return (await headers()).get("x-faults")?.trim() === faultName; + } catch { + return false; + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function sqlFromStatement(statement: unknown): string | undefined { + if (typeof statement === "string") { + return statement; + } + if (Array.isArray(statement) && typeof statement[0] === "string") { + return statement[0]; + } + if (isRecord(statement) && typeof statement.sql === "string") { + return statement.sql; + } + + return undefined; +} + +function sqlOperation(sql: string | undefined) { + return sql?.trim().split(/\s+/, 1)[0]?.toLowerCase(); +} + +function sqlCollection(sql: string | undefined) { + if (!sql) return undefined; + + const normalized = sql.replace(/["`]/g, " "); + const match = normalized.match( + /\b(?:from|into|update|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)/i, + ); + + return match?.[1]?.toLowerCase(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function createDatabase() { if (databaseConfig.mode === "proxy") { return drizzleProxy(executeProxyQuery, executeProxyBatch, { schema }); @@ -100,7 +258,7 @@ function createDatabase() { }, ); - return drizzleLibsql(client, { schema }); + return drizzleLibsql(instrumentLibsqlClient(client), { schema }); } export const db = createDatabase(); diff --git a/lib/db/queries.ts b/lib/db/queries.ts index c0d1c18..9473b9c 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,6 +1,7 @@ import { and, desc, eq, isNull } from "drizzle-orm"; import { cookies } from "next/headers"; import { verifyToken } from "@/lib/auth/session"; +import { withSpan } from "@/lib/telemetry"; import { db } from "./drizzle"; import { activityLogs, @@ -11,35 +12,56 @@ import { } from "./schema"; export async function getUser() { - const sessionCookie = (await cookies()).get("session"); - if (!sessionCookie || !sessionCookie.value) { - return null; - } - - const sessionData = await verifyToken(sessionCookie.value); - if ( - !sessionData || - !sessionData.user || - typeof sessionData.user.id !== "number" - ) { - return null; - } - - if (new Date(sessionData.expires) < new Date()) { - return null; - } - - const user = await db - .select() - .from(users) - .where(and(eq(users.id, sessionData.user.id), isNull(users.deletedAt))) - .limit(1); - - if (user.length === 0) { - return null; - } - - return user[0]; + return withSpan( + "auth.user.lookup", + { "app.operation": "auth.user.lookup" }, + async (span) => { + const sessionCookie = (await cookies()).get("session"); + if (!sessionCookie?.value) { + span?.setAttribute("app.authenticated", false); + span?.setAttribute("app.result", "session_missing"); + + return null; + } + + const sessionData = await withSpan( + "auth.session.verify", + { "app.operation": "auth.session.verify" }, + async () => verifyToken(sessionCookie.value), + ); + if (!sessionData?.user || typeof sessionData.user.id !== "number") { + span?.setAttribute("app.authenticated", false); + span?.setAttribute("app.result", "session_invalid"); + + return null; + } + + if (new Date(sessionData.expires) < new Date()) { + span?.setAttribute("app.authenticated", false); + span?.setAttribute("app.result", "session_expired"); + + return null; + } + + const user = await db + .select() + .from(users) + .where(and(eq(users.id, sessionData.user.id), isNull(users.deletedAt))) + .limit(1); + + if (user.length === 0) { + span?.setAttribute("app.authenticated", false); + span?.setAttribute("app.result", "user_missing"); + + return null; + } + + span?.setAttribute("app.authenticated", true); + span?.setAttribute("app.result", "user_found"); + + return user[0]; + }, + ); } export async function updateTeamSubscription( @@ -49,13 +71,22 @@ export async function updateTeamSubscription( subscriptionStatus: string; }, ) { - await db - .update(teams) - .set({ - ...subscriptionData, - updatedAt: new Date(), - }) - .where(eq(teams.id, teamId)); + await withSpan( + "team.subscription.update", + { + "app.operation": "team.subscription.update", + "subscription.status": subscriptionData.subscriptionStatus, + "subscription.plan": subscriptionData.planName ?? "none", + }, + async () => + db + .update(teams) + .set({ + ...subscriptionData, + updatedAt: new Date(), + }) + .where(eq(teams.id, teamId)), + ); } export async function getUserWithTeam(userId: number) { @@ -73,61 +104,85 @@ export async function getUserWithTeam(userId: number) { } export async function getActivityLogs() { - const user = await getUser(); - if (!user) { - throw new Error("User not authenticated"); - } - - return await db - .select({ - id: activityLogs.id, - action: activityLogs.action, - timestamp: activityLogs.timestamp, - ipAddress: activityLogs.ipAddress, - userName: users.name, - }) - .from(activityLogs) - .leftJoin(users, eq(activityLogs.userId, users.id)) - .where(eq(activityLogs.userId, user.id)) - .orderBy(desc(activityLogs.timestamp)) - .limit(10); + return withSpan( + "activity.logs.get", + { "app.operation": "activity.logs.get" }, + async (span) => { + const user = await getUser(); + if (!user) { + span?.setAttribute("app.authenticated", false); + throw new Error("User not authenticated"); + } + + const logs = await db + .select({ + id: activityLogs.id, + action: activityLogs.action, + timestamp: activityLogs.timestamp, + ipAddress: activityLogs.ipAddress, + userName: users.name, + }) + .from(activityLogs) + .leftJoin(users, eq(activityLogs.userId, users.id)) + .where(eq(activityLogs.userId, user.id)) + .orderBy(desc(activityLogs.timestamp)) + .limit(10); + + span?.setAttribute("activity.count", logs.length); + + return logs; + }, + ); } export async function getTeamForUser() { - const user = await getUser(); - if (!user) { - return null; - } - - const result = await db.query.teamMembers.findFirst({ - where: eq(teamMembers.userId, user.id), - with: { - team: { + return withSpan( + "team.for_user.get", + { "app.operation": "team.for_user.get" }, + async (span) => { + const user = await getUser(); + if (!user) { + span?.setAttribute("app.authenticated", false); + + return null; + } + + const result = await db.query.teamMembers.findFirst({ + where: eq(teamMembers.userId, user.id), with: { - teamMembers: { + team: { with: { - user: { - columns: { - id: true, - name: true, - email: true, + teamMembers: { + with: { + user: { + columns: { + id: true, + name: true, + email: true, + }, + }, }, }, }, }, }, - }, - }, - }); + }); - if (!result?.team) { - return null; - } + if (!result?.team) { + span?.setAttribute("app.result", "team_missing"); - return { - ...result.team, - teamMembers: [...result.team.teamMembers].sort(compareTeamMembers), - }; + return null; + } + + span?.setAttribute("app.result", "team_found"); + span?.setAttribute("team.member_count", result.team.teamMembers.length); + + return { + ...result.team, + teamMembers: [...result.team.teamMembers].sort(compareTeamMembers), + }; + }, + ); } function compareTeamMembers( diff --git a/lib/payments/actions.ts b/lib/payments/actions.ts index e782741..dd7a429 100644 --- a/lib/payments/actions.ts +++ b/lib/payments/actions.ts @@ -7,6 +7,7 @@ import { db } from "@/lib/db/drizzle"; import { getUser } from "@/lib/db/queries"; import { payments, teamMembers, teams } from "@/lib/db/schema"; import { isFaultActive } from "@/lib/faults"; +import { withActionSpan, withSpan } from "@/lib/telemetry"; const paymentSchema = z.object({ cardNumber: z @@ -31,76 +32,109 @@ const paymentSchema = z.object({ }); export async function processPayment(formData: FormData) { - const user = await getUser(); - if (!user) { - redirect("/sign-in?redirect=pricing"); - } - - // Get user's team - const userTeam = await db - .select({ - teamId: teamMembers.teamId, - }) - .from(teamMembers) - .where(eq(teamMembers.userId, user.id)) - .limit(1); - - if (userTeam.length === 0) { - throw new Error("User is not associated with any team"); - } + return withActionSpan("process_payment", async (span) => { + const user = await getUser(); + if (!user) { + span?.setAttribute("app.authenticated", false); + redirect("/sign-in?redirect=pricing"); + } - const data = { - cardNumber: formData.get("cardNumber") as string, - cardHolderName: formData.get("cardHolderName") as string, - expiryDate: formData.get("expiryDate") as string, - cvv: formData.get("cvv") as string, - billingAddress: formData.get("billingAddress") as string, - city: formData.get("city") as string, - state: formData.get("state") as string, - zipCode: formData.get("zipCode") as string, - country: formData.get("country") as string, - planName: formData.get("planName") as string, - amount: Number(formData.get("amount")), - }; + // Get user's team + const userTeam = await db + .select({ + teamId: teamMembers.teamId, + }) + .from(teamMembers) + .where(eq(teamMembers.userId, user.id)) + .limit(1); - try { - const validatedData = paymentSchema.parse(data); - if (await isFaultActive("payment-server-error")) { - throw new Error("Fault injected: payment-server-error"); + if (userTeam.length === 0) { + span?.setAttribute("app.result", "team_missing"); + throw new Error("User is not associated with any team"); } - const paymentData = { - teamId: userTeam[0].teamId, - ...validatedData, - amount: (await isFaultActive("payment-wrong-amount")) - ? 800 - : validatedData.amount, + + const data = { + cardNumber: formData.get("cardNumber") as string, + cardHolderName: formData.get("cardHolderName") as string, + expiryDate: formData.get("expiryDate") as string, + cvv: formData.get("cvv") as string, + billingAddress: formData.get("billingAddress") as string, + city: formData.get("city") as string, + state: formData.get("state") as string, + zipCode: formData.get("zipCode") as string, + country: formData.get("country") as string, + planName: formData.get("planName") as string, + amount: Number(formData.get("amount")), }; - if (!(await isFaultActive("payment-row-missing"))) { - await db.insert(payments).values(paymentData); - if (await isFaultActive("payment-duplicate-charge")) { - await db.insert(payments).values(paymentData); + try { + const validatedData = paymentSchema.parse(data); + if (await isFaultActive("payment-server-error")) { + throw new Error("Fault injected: payment-server-error"); } - } + const paymentData = { + teamId: userTeam[0].teamId, + ...validatedData, + amount: (await isFaultActive("payment-wrong-amount")) + ? 800 + : validatedData.amount, + }; - if (!(await isFaultActive("payment-subscription-update-skipped"))) { - await db - .update(teams) - .set({ - planName: validatedData.planName, - subscriptionStatus: "active", - updatedAt: new Date(), - }) - .where(eq(teams.id, userTeam[0].teamId)); - } + if (!(await isFaultActive("payment-row-missing"))) { + await withSpan( + "payment.recorded", + { + "app.operation": "payment.recorded", + "payment.plan": paymentData.planName, + "payment.amount": paymentData.amount, + "payment.currency": "USD", + }, + async () => db.insert(payments).values(paymentData), + ); + if (await isFaultActive("payment-duplicate-charge")) { + await withSpan( + "payment.recorded", + { + "app.operation": "payment.recorded", + "payment.plan": paymentData.planName, + "payment.amount": paymentData.amount, + "payment.currency": "USD", + }, + async () => db.insert(payments).values(paymentData), + ); + } + } - redirect("/dashboard?payment=success"); - } catch (error) { - if (error instanceof z.ZodError) { - throw new Error(error.issues.map((e) => e.message).join(", ")); + if (!(await isFaultActive("payment-subscription-update-skipped"))) { + await withSpan( + "team.subscription.updated", + { + "app.operation": "team.subscription.updated", + "subscription.plan": validatedData.planName, + "subscription.status": "active", + }, + async () => + db + .update(teams) + .set({ + planName: validatedData.planName, + subscriptionStatus: "active", + updatedAt: new Date(), + }) + .where(eq(teams.id, userTeam[0].teamId)), + ); + } + + span?.setAttribute("app.result", "payment_processed"); + redirect("/dashboard?payment=success"); + } catch (error) { + span?.setAttribute("app.result", "error"); + if (error instanceof z.ZodError) { + throw new Error(error.issues.map((e) => e.message).join(", ")); + } + throw error; } - throw error; - } + }); } export async function checkoutAction(formData: FormData) { diff --git a/lib/telemetry.ts b/lib/telemetry.ts new file mode 100644 index 0000000..18b150c --- /dev/null +++ b/lib/telemetry.ts @@ -0,0 +1,54 @@ +import { + type Attributes, + type Span, + SpanStatusCode, + trace, +} from "@opentelemetry/api"; + +export function isTelemetryEnabled() { + return Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT); +} + +export async function withSpan( + name: string, + attributes: Attributes, + fn: (span?: Span) => Promise, +) { + if (!isTelemetryEnabled()) { + return fn(); + } + + const tracer = trace.getTracer("playwright-tutorial-next"); + + return tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + const result = await fn(span); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (error) { + span.recordException(error as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + span.end(); + } + }); +} + +export async function withActionSpan( + operation: string, + fn: (span?: Span) => Promise, +) { + return withSpan( + `action.${operation}`, + { "app.operation": `action.${operation}` }, + fn, + ); +} + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/package.json b/package.json index 78abeeb..5fa7c14 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ }, "dependencies": { "@libsql/client": "^0.17.4", + "@opentelemetry/api": "^1.9.1", "@tailwindcss/postcss": "4.3.1", "@types/node": "^24.13.2", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", + "@vercel/otel": "^2.1.3", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15c9dc5..1a1321c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@libsql/client': specifier: ^0.17.4 version: 0.17.4 + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 '@tailwindcss/postcss': specifier: 4.3.1 version: 4.3.1 @@ -23,6 +26,9 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.17) + '@vercel/otel': + specifier: ^2.1.3 + version: 2.1.3(@opentelemetry/api-logs@0.219.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -40,7 +46,7 @@ importers: version: 0.31.10 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@libsql/client@0.17.4)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.2.0)(bun-types@1.3.14)(gel@2.1.0)(postgres@3.4.5) + version: 0.45.2(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.2.0)(bun-types@1.3.14)(gel@2.1.0)(postgres@3.4.5) jose: specifier: ^6.2.3 version: 6.2.3 @@ -49,7 +55,7 @@ importers: version: 1.21.0(react@19.2.7) next: specifier: 16.2.9 - version: 16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) postcss: specifier: ^8.5.15 version: 8.5.15 @@ -1079,6 +1085,54 @@ packages: cpu: [x64] os: [win32] + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.219.0': + resolution: {integrity: sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -1895,10 +1949,32 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vercel/otel@2.1.3': + resolution: {integrity: sha512-Ofvzs9qhftRD1YMLuPnhbXjQZG6IKrJ9AmEKmRHRGfoWlV89ed2gAOkvddkFiZDFZJm2rrFNdeZKRVxoCdnWiw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <2.0.0' + '@opentelemetry/api-logs': '>=0.200.0 <0.300.0' + '@opentelemetry/instrumentation': '>=0.200.0 <0.300.0' + '@opentelemetry/resources': '>=2.0.0 <3.0.0' + '@opentelemetry/sdk-logs': '>=0.200.0 <0.300.0' + '@opentelemetry/sdk-metrics': '>=2.0.0 <3.0.0' + '@opentelemetry/sdk-trace-base': '>=2.0.0 <3.0.0' + '@zip.js/zip.js@2.8.26': resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1940,6 +2016,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2181,6 +2260,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + import-in-the-middle@3.1.0: + resolution: {integrity: sha512-c0AeAV8VcwZzfYE7euTZY3H+VXUPMVugiovdosq80lqEXJmOekg3zGUAYg6KImHMaMuBoTUfTv7xNpUFdy0hJA==} + engines: {node: '>=18'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2298,6 +2381,9 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2444,6 +2530,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3184,6 +3274,55 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.9': optional: true + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + import-in-the-middle: 3.1.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@petamoriken/float16@3.9.3': optional: true @@ -4037,8 +4176,24 @@ snapshots: dependencies: '@types/node': 25.0.3 + '@vercel/otel@2.1.3(@opentelemetry/api-logs@0.219.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@zip.js/zip.js@2.8.26': {} + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -4085,6 +4240,8 @@ snapshots: chownr@1.1.4: optional: true + cjs-module-lexer@2.2.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -4098,7 +4255,6 @@ snapshots: debug@4.4.3: dependencies: ms: 2.1.3 - optional: true decompress-response@6.0.0: dependencies: @@ -4125,9 +4281,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@libsql/client@0.17.4)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.2.0)(bun-types@1.3.14)(gel@2.1.0)(postgres@3.4.5): + drizzle-orm@0.45.2(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.2.0)(bun-types@1.3.14)(gel@2.1.0)(postgres@3.4.5): optionalDependencies: '@libsql/client': 0.17.4 + '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 better-sqlite3: 12.2.0 bun-types: 1.3.14 @@ -4319,6 +4476,13 @@ snapshots: ieee754@1.2.1: optional: true + import-in-the-middle@3.1.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + inherits@2.0.4: optional: true @@ -4415,8 +4579,9 @@ snapshots: mkdirp-classic@0.5.3: optional: true - ms@2.1.3: - optional: true + module-details-from-path@1.0.4: {} + + ms@2.1.3: {} nanoid@3.3.11: {} @@ -4425,7 +4590,7 @@ snapshots: napi-build-utils@2.0.0: optional: true - next@16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 '@swc/helpers': 0.5.15 @@ -4444,6 +4609,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.9 '@next/swc-win32-arm64-msvc': 16.2.9 '@next/swc-win32-x64-msvc': 16.2.9 + '@opentelemetry/api': 1.9.1 '@playwright/test': 1.61.0 sharp: 0.34.5 transitivePeerDependencies: @@ -4626,6 +4792,13 @@ snapshots: util-deprecate: 1.0.2 optional: true + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-pkg-maps@1.0.0: {} safe-buffer@5.2.1: diff --git a/tests/support/faults.ts b/tests/support/faults.ts index 8e6c6f0..7c4d1b0 100644 --- a/tests/support/faults.ts +++ b/tests/support/faults.ts @@ -2,6 +2,9 @@ import type { Page, TestInfo } from "@playwright/test"; export const implementedFaults = [ "api-team-500", + "api-team-extra-request", + "api-team-db-latency-spike", + "api-team-db-read-skipped", "api-team-latency-spike", "api-team-malformed-json", "api-user-malformed-json", @@ -36,6 +39,15 @@ const faultTargets: Record = { "api-team-500": [ "should change user name in general settings and verify it appears in team members list", ], + "api-team-extra-request": [ + "should change user name in general settings and verify it appears in team members list", + ], + "api-team-db-latency-spike": [ + "should change user name in general settings and verify it appears in team members list", + ], + "api-team-db-read-skipped": [ + "should change user name in general settings and verify it appears in team members list", + ], "api-team-latency-spike": [ "should change user name in general settings and verify it appears in team members list", ], @@ -116,6 +128,9 @@ const FAULT_HEADER = "x-faults"; const faultInstallers: Record Promise> = { "api-team-500": installApiTeam500, + "api-team-extra-request": installApiTeamExtraRequest, + "api-team-db-latency-spike": installApiTeamDbLatencySpike, + "api-team-db-read-skipped": installApiTeamDbReadSkipped, "api-team-latency-spike": installApiTeamLatencySpike, "api-team-malformed-json": installApiTeamMalformedJson, "api-user-malformed-json": installApiUserMalformedJson, @@ -188,6 +203,44 @@ async function installApiTeam500(page: Page) { }); } +async function installApiTeamExtraRequest(page: Page) { + await page.addInitScript(() => { + const originalFetch = window.fetch.bind(window) as typeof window.fetch; + let duplicated = false; + + const faultFetch = Object.assign( + async (...args: Parameters) => { + const [input] = args; + const response = await originalFetch(...args); + const url = + typeof input === "string" || input instanceof URL + ? input.toString() + : input.url; + + if ( + !duplicated && + new URL(url, window.location.href).pathname === "/api/team" + ) { + duplicated = true; + void originalFetch(...args).catch(() => undefined); + } + + return response; + }, + originalFetch, + ) as typeof window.fetch; + window.fetch = faultFetch; + }); +} + +async function installApiTeamDbLatencySpike(page: Page) { + await setAppFaultHeader(page, "api-team-db-latency-spike"); +} + +async function installApiTeamDbReadSkipped(page: Page) { + await setAppFaultHeader(page, "api-team-db-read-skipped"); +} + async function installApiTeamMalformedJson(page: Page) { const faultName = "api-team-malformed-json"; From 92fa55f69142d8ecdbe8c7c50a8456b17e5685e1 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Wed, 24 Jun 2026 12:47:54 +0200 Subject: [PATCH 2/3] Simplify otel implementation --- app/(login)/actions.ts | 179 ++++++++++++++---------------- instrumentation.ts | 7 +- lib/auth/activity.ts | 36 ++----- lib/auth/login.ts | 233 ++++++++++++++++++---------------------- lib/auth/middleware.ts | 35 +++--- lib/auth/session.ts | 46 +++----- lib/db/drizzle.ts | 88 ++++++++------- lib/db/queries.ts | 209 +++++++++++++---------------------- lib/payments/actions.ts | 156 +++++++++++---------------- lib/telemetry.ts | 15 --- 10 files changed, 413 insertions(+), 591 deletions(-) diff --git a/app/(login)/actions.ts b/app/(login)/actions.ts index 8731b31..f22b1cf 100644 --- a/app/(login)/actions.ts +++ b/app/(login)/actions.ts @@ -23,70 +23,65 @@ import { users, } from "@/lib/db/schema"; import { isFaultActive } from "@/lib/faults"; -import { withActionSpan, withSpan } from "@/lib/telemetry"; const signInSchema = z.object({ email: z.string().email().min(3).max(255), password: z.string().min(8).max(100), }); -export const signIn = validatedAction( - signInSchema, - "sign_in", - async (data, formData) => { - const { email, password } = data; - - const userWithTeam = await db - .select({ - user: users, - team: teams, - }) - .from(users) - .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) - .leftJoin(teams, eq(teamMembers.teamId, teams.id)) - .where(eq(users.email, email)) - .limit(1); +export const signIn = validatedAction(signInSchema, async (data, formData) => { + const { email, password } = data; + + const userWithTeam = await db + .select({ + user: users, + team: teams, + }) + .from(users) + .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) + .leftJoin(teams, eq(teamMembers.teamId, teams.id)) + .where(eq(users.email, email)) + .limit(1); + + if (userWithTeam.length === 0) { + return { + error: "Invalid email or password. Please try again.", + email, + password, + }; + } - if (userWithTeam.length === 0) { - return { - error: "Invalid email or password. Please try again.", - email, - password, - }; - } + const { user: foundUser, team: foundTeam } = userWithTeam[0]; - const { user: foundUser, team: foundTeam } = userWithTeam[0]; + const isPasswordValid = await comparePasswords( + password, + foundUser.passwordHash, + ); - const isPasswordValid = await comparePasswords( + if (!isPasswordValid) { + return { + error: "Invalid email or password. Please try again.", + email, password, - foundUser.passwordHash, + }; + } + + await Promise.all([ + setSession(foundUser), + logActivity(foundTeam?.id, foundUser.id, ActivityType.SIGN_IN), + ]); + + const redirectTo = formData.get("redirect") as string | null; + if (redirectTo === "checkout") { + const plan = formData.get("plan") as string; + const amount = formData.get("amount") as string; + redirect( + `/pricing/checkout?plan=${encodeURIComponent(plan)}&amount=${amount}`, ); + } - if (!isPasswordValid) { - return { - error: "Invalid email or password. Please try again.", - email, - password, - }; - } - - await Promise.all([ - setSession(foundUser), - logActivity(foundTeam?.id, foundUser.id, ActivityType.SIGN_IN), - ]); - - const redirectTo = formData.get("redirect") as string | null; - if (redirectTo === "checkout") { - const plan = formData.get("plan") as string; - const amount = formData.get("amount") as string; - redirect( - `/pricing/checkout?plan=${encodeURIComponent(plan)}&amount=${amount}`, - ); - } - - redirect("/dashboard"); - }, -); + redirect("/dashboard"); +}); const signUpSchema = z.object({ email: z.string().email(), @@ -94,47 +89,34 @@ const signUpSchema = z.object({ inviteId: z.string().optional(), }); -export const signUp = validatedAction( - signUpSchema, - "sign_up", - async (data, formData) => { - const { email, password, inviteId } = data; +export const signUp = validatedAction(signUpSchema, async (data, formData) => { + const { email, password, inviteId } = data; - const createdUser = await signup(email, password, inviteId); + const createdUser = await signup(email, password, inviteId); - await setSession(createdUser as NewUser); + await setSession(createdUser as NewUser); - const redirectTo = formData.get("redirect") as string | null; - if (redirectTo === "checkout") { - const plan = formData.get("plan") as string; - const amount = formData.get("amount") as string; - redirect( - `/pricing/checkout?plan=${encodeURIComponent(plan)}&amount=${amount}`, - ); - } + const redirectTo = formData.get("redirect") as string | null; + if (redirectTo === "checkout") { + const plan = formData.get("plan") as string; + const amount = formData.get("amount") as string; + redirect( + `/pricing/checkout?plan=${encodeURIComponent(plan)}&amount=${amount}`, + ); + } - redirect("/dashboard"); - }, -); + redirect("/dashboard"); +}); export async function signOut() { - await withActionSpan("sign_out", async (span) => { - const user = (await getUser()) as User; - const userWithTeam = await getUserWithTeam(user.id); - if (!(await isFaultActive("signout-activity-log-missing"))) { - await logActivity(userWithTeam?.teamId, user.id, ActivityType.SIGN_OUT); - } - if (!(await isFaultActive("signout-cookie-not-cleared"))) { - await withSpan( - "auth.session.delete", - { "app.operation": "auth.session.delete" }, - async () => (await cookies()).delete("session"), - ); - span?.setAttribute("app.result", "session_deleted"); - } else { - span?.setAttribute("app.result", "session_delete_skipped"); - } - }); + const user = (await getUser()) as User; + const userWithTeam = await getUserWithTeam(user.id); + if (!(await isFaultActive("signout-activity-log-missing"))) { + await logActivity(userWithTeam?.teamId, user.id, ActivityType.SIGN_OUT); + } + if (!(await isFaultActive("signout-cookie-not-cleared"))) { + (await cookies()).delete("session"); + } } const updatePasswordSchema = z.object({ @@ -145,7 +127,6 @@ const updatePasswordSchema = z.object({ export const updatePassword = validatedActionWithUser( updatePasswordSchema, - "update_password", async (data, _, user) => { const { currentPassword, newPassword, confirmPassword } = data; @@ -211,7 +192,6 @@ const deleteAccountSchema = z.object({ export const deleteAccount = validatedActionWithUser( deleteAccountSchema, - "delete_account", async (data, _, user) => { const { password } = data; @@ -263,7 +243,6 @@ const updateAccountSchema = z.object({ export const updateAccount = validatedActionWithUser( updateAccountSchema, - "update_account", async (data, _, user) => { const { name, email } = data; const userWithTeam = await getUserWithTeam(user.id); @@ -296,7 +275,6 @@ const removeTeamMemberSchema = z.object({ export const removeTeamMember = validatedActionWithUser( removeTeamMemberSchema, - "remove_team_member", async (data, _, user) => { const { memberId } = data; const userWithTeam = await getUserWithTeam(user.id); @@ -331,7 +309,6 @@ const inviteTeamMemberSchema = z.object({ export const inviteTeamMember = validatedActionWithUser( inviteTeamMemberSchema, - "invite_team_member", async (data, _, user) => { const { email, role } = data; const userWithTeam = await getUserWithTeam(user.id); @@ -339,13 +316,17 @@ export const inviteTeamMember = validatedActionWithUser( if (!userWithTeam?.teamId) { return { error: "User is not part of a team" }; } - const teamId = userWithTeam.teamId; const existingMember = await db .select() .from(users) .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) - .where(and(eq(users.email, email), eq(teamMembers.teamId, teamId))) + .where( + and( + eq(users.email, email), + eq(teamMembers.teamId, userWithTeam.teamId), + ), + ) .limit(1); if (existingMember.length > 0) { @@ -359,7 +340,7 @@ export const inviteTeamMember = validatedActionWithUser( .where( and( eq(invitations.email, email), - eq(invitations.teamId, teamId), + eq(invitations.teamId, userWithTeam.teamId), eq(invitations.status, "pending"), ), ) @@ -376,7 +357,7 @@ export const inviteTeamMember = validatedActionWithUser( const invitation = await db .insert(invitations) .values({ - teamId, + teamId: userWithTeam.teamId, email, role, invitedBy: user.id, @@ -384,7 +365,11 @@ export const inviteTeamMember = validatedActionWithUser( }) .returning(); - await logActivity(teamId, user.id, ActivityType.INVITE_TEAM_MEMBER); + await logActivity( + userWithTeam.teamId, + user.id, + ActivityType.INVITE_TEAM_MEMBER, + ); // TODO: Send invitation email and include ?inviteId={id} to sign-up URL // await sendInvitationEmail(email, userWithTeam.team.name, role) diff --git a/instrumentation.ts b/instrumentation.ts index 25233de..fd2dc99 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,17 +1,16 @@ +import { registerOTel } from "@vercel/otel"; + function backendOtelEnabled() { return Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT); } -export async function register() { +export function register() { if (!backendOtelEnabled()) { return; } - const { registerOTel } = await import("@vercel/otel"); - registerOTel({ serviceName: process.env.OTEL_SERVICE_NAME || "playwright-tutorial-next", - traceSampler: "always_on", attributes: { "service.namespace": "playwright-tutorial", "endform.telemetry.source": "next-app", diff --git a/lib/auth/activity.ts b/lib/auth/activity.ts index 648d731..cb7d638 100644 --- a/lib/auth/activity.ts +++ b/lib/auth/activity.ts @@ -4,7 +4,6 @@ import { activityLogs, type NewActivityLog, } from "@/lib/db/schema"; -import { withSpan } from "@/lib/telemetry"; export async function logActivity( teamId: number | null | undefined, @@ -12,29 +11,14 @@ export async function logActivity( type: ActivityType, ipAddress?: string, ) { - await withSpan( - "activity.log", - { - "app.operation": "activity.log", - "activity.type": type, - }, - async (span) => { - if (teamId === null || teamId === undefined) { - span?.setAttribute("app.result", "team_missing"); - - return; - } - - const newActivity: NewActivityLog = { - teamId, - userId, - action: type, - ipAddress: ipAddress || "", - }; - - await db.insert(activityLogs).values(newActivity); - - span?.setAttribute("app.result", "activity_created"); - }, - ); + if (teamId === null || teamId === undefined) { + return; + } + const newActivity: NewActivityLog = { + teamId, + userId, + action: type, + ipAddress: ipAddress || "", + }; + await db.insert(activityLogs).values(newActivity); } diff --git a/lib/auth/login.ts b/lib/auth/login.ts index a632ac0..02a7c1a 100644 --- a/lib/auth/login.ts +++ b/lib/auth/login.ts @@ -12,7 +12,6 @@ import { users, } from "@/lib/db/schema"; import { isFaultActive } from "@/lib/faults"; -import { withSpan } from "@/lib/telemetry"; import { logActivity } from "./activity"; export async function signup( @@ -20,141 +19,115 @@ export async function signup( password: string, inviteId?: string, ) { - return withSpan( - "auth.signup", - { - "app.operation": "auth.signup", - "auth.invitation_present": Boolean(inviteId), - }, - async (span) => { - const existingUser = await db + const existingUser = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (existingUser.length > 0) { + return { + error: "Failed to create user. Please try again.", + email, + password, + }; + } + + const passwordHash = await hashPassword(password); + + const newUser: NewUser = { + email, + passwordHash, + role: "owner", // Default role, will be overridden if there's an invitation + }; + + const [createdUser] = await db.insert(users).values(newUser).returning(); + + if (!createdUser) { + return { + error: "Failed to create user. Please try again.", + email, + password, + }; + } + + let teamId: number; + let userRole: string; + let createdTeam: typeof teams.$inferSelect | null = null; + + if (inviteId) { + // Check if there's a valid invitation + const [invitation] = await db + .select() + .from(invitations) + .where( + and( + eq(invitations.id, parseInt(inviteId, 10)), + eq(invitations.email, email), + eq(invitations.status, "pending"), + ), + ) + .limit(1); + + if (invitation) { + teamId = invitation.teamId; + userRole = (await isFaultActive("invite-role-drift")) + ? "owner" + : invitation.role; + + await db + .update(invitations) + .set({ status: "accepted" }) + .where(eq(invitations.id, invitation.id)); + + await logActivity(teamId, createdUser.id, ActivityType.ACCEPT_INVITATION); + + [createdTeam] = await db .select() - .from(users) - .where(eq(users.email, email)) + .from(teams) + .where(eq(teams.id, teamId)) .limit(1); + } else { + return { error: "Invalid or expired invitation.", email, password }; + } + } else { + // Create a new team if there's no invitation + const newTeam: NewTeam = { + name: `${email}'s Team`, + }; + + [createdTeam] = await db.insert(teams).values(newTeam).returning(); + + if (!createdTeam) { + return { + error: "Failed to create team. Please try again.", + email, + password, + }; + } - if (existingUser.length > 0) { - span?.setAttribute("app.result", "user_exists"); + teamId = createdTeam.id; + userRole = "owner"; - return { - error: "Failed to create user. Please try again.", - email, - password, - }; - } + await logActivity(teamId, createdUser.id, ActivityType.CREATE_TEAM); + } - const passwordHash = await hashPassword(password); + const newTeamMember: NewTeamMember = { + userId: createdUser.id, + teamId: teamId, + role: userRole, + }; - const newUser: NewUser = { - email, - passwordHash, - role: "owner", // Default role, will be overridden if there's an invitation - }; + const skipTeamMembership = + Boolean(inviteId) && + (await isFaultActive("invite-accepted-but-member-missing")); - const [createdUser] = await db.insert(users).values(newUser).returning(); - - if (!createdUser) { - span?.setAttribute("app.result", "user_create_failed"); - - return { - error: "Failed to create user. Please try again.", - email, - password, - }; - } - - let teamId: number; - let userRole: string; - let createdTeam: typeof teams.$inferSelect | null = null; - - if (inviteId) { - // Check if there's a valid invitation - const [invitation] = await db - .select() - .from(invitations) - .where( - and( - eq(invitations.id, parseInt(inviteId, 10)), - eq(invitations.email, email), - eq(invitations.status, "pending"), - ), - ) - .limit(1); - - if (invitation) { - teamId = invitation.teamId; - userRole = (await isFaultActive("invite-role-drift")) - ? "owner" - : invitation.role; - - await db - .update(invitations) - .set({ status: "accepted" }) - .where(eq(invitations.id, invitation.id)); - - await logActivity( - teamId, - createdUser.id, - ActivityType.ACCEPT_INVITATION, - ); - - [createdTeam] = await db - .select() - .from(teams) - .where(eq(teams.id, teamId)) - .limit(1); - } else { - span?.setAttribute("app.result", "invitation_invalid"); - - return { error: "Invalid or expired invitation.", email, password }; - } - } else { - // Create a new team if there's no invitation - const newTeam: NewTeam = { - name: `${email}'s Team`, - }; - - [createdTeam] = await db.insert(teams).values(newTeam).returning(); - - if (!createdTeam) { - span?.setAttribute("app.result", "team_create_failed"); - - return { - error: "Failed to create team. Please try again.", - email, - password, - }; - } - - teamId = createdTeam.id; - userRole = "owner"; - - await logActivity(teamId, createdUser.id, ActivityType.CREATE_TEAM); - } - - const newTeamMember: NewTeamMember = { - userId: createdUser.id, - teamId: teamId, - role: userRole, - }; + await Promise.all([ + ...(skipTeamMembership + ? [] + : [db.insert(teamMembers).values(newTeamMember)]), + logActivity(teamId, createdUser.id, ActivityType.SIGN_UP), + ]); - const skipTeamMembership = - Boolean(inviteId) && - (await isFaultActive("invite-accepted-but-member-missing")); - - await Promise.all([ - ...(skipTeamMembership - ? [] - : [db.insert(teamMembers).values(newTeamMember)]), - logActivity(teamId, createdUser.id, ActivityType.SIGN_UP), - ]); - - span?.setAttribute( - "app.result", - skipTeamMembership ? "team_membership_skipped" : "user_created", - ); - - return createdUser as NewUser; - }, - ); + return createdUser as NewUser; } diff --git a/lib/auth/middleware.ts b/lib/auth/middleware.ts index 828666d..1694c0d 100644 --- a/lib/auth/middleware.ts +++ b/lib/auth/middleware.ts @@ -2,7 +2,6 @@ import { redirect } from "next/navigation"; import type { z } from "zod"; import { getTeamForUser, getUser } from "@/lib/db/queries"; import type { TeamDataWithMembers, User } from "@/lib/db/schema"; -import { withActionSpan } from "@/lib/telemetry"; export type ActionState = { error?: string; @@ -17,18 +16,15 @@ type ValidatedActionFunction = ( export function validatedAction( schema: S, - operation: string, action: ValidatedActionFunction, ) { return async (_prevState: ActionState, formData: FormData) => { - return withActionSpan(operation, async () => { - const result = schema.safeParse(Object.fromEntries(formData)); - if (!result.success) { - return { error: result.error.issues[0].message }; - } + const result = schema.safeParse(Object.fromEntries(formData)); + if (!result.success) { + return { error: result.error.issues[0].message }; + } - return action(result.data, formData); - }); + return action(result.data, formData); }; } @@ -40,23 +36,20 @@ type ValidatedActionWithUserFunction = ( export function validatedActionWithUser( schema: S, - operation: string, action: ValidatedActionWithUserFunction, ) { return async (_prevState: ActionState, formData: FormData) => { - return withActionSpan(operation, async () => { - const user = await getUser(); - if (!user) { - throw new Error("User is not authenticated"); - } + const user = await getUser(); + if (!user) { + throw new Error("User is not authenticated"); + } - const result = schema.safeParse(Object.fromEntries(formData)); - if (!result.success) { - return { error: result.error.issues[0].message }; - } + const result = schema.safeParse(Object.fromEntries(formData)); + if (!result.success) { + return { error: result.error.issues[0].message }; + } - return action(result.data, formData, user); - }); + return action(result.data, formData, user); }; } diff --git a/lib/auth/session.ts b/lib/auth/session.ts index c58fd00..a61fef3 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -2,7 +2,6 @@ import { compare, hash } from "bcryptjs"; import { jwtVerify, SignJWT } from "jose"; import { cookies } from "next/headers"; import type { NewUser } from "@/lib/db/schema"; -import { withSpan } from "@/lib/telemetry"; const DEMO_AUTH_SECRET = "playwright-tutorial-demo-auth-secret"; const key = new TextEncoder().encode( @@ -48,37 +47,24 @@ export async function getSession() { } export async function createSession(userId: number, expires: Date) { - return withSpan( - "auth.session.create", - { "app.operation": "auth.session.create" }, - async () => { - const session: SessionData = { - user: { id: userId }, - expires: expires.toISOString(), - }; - - return await signToken(session); - }, - ); + const session: SessionData = { + user: { id: userId }, + expires: expires.toISOString(), + }; + return await signToken(session); } export async function setSession(user: NewUser) { - await withSpan( - "auth.session.set_cookie", - { "app.operation": "auth.session.set_cookie" }, - async () => { - if (user.id == null) { - throw new Error("Cannot create a session for a user without an id"); - } + if (user.id == null) { + throw new Error("Cannot create a session for a user without an id"); + } - const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); - const encryptedSession = await createSession(user.id, expiresInOneDay); - (await cookies()).set("session", encryptedSession, { - expires: expiresInOneDay, - httpOnly: true, - secure: true, - sameSite: "lax", - }); - }, - ); + const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); + const encryptedSession = await createSession(user.id, expiresInOneDay); + (await cookies()).set("session", encryptedSession, { + expires: expiresInOneDay, + httpOnly: true, + secure: true, + sameSite: "lax", + }); } diff --git a/lib/db/drizzle.ts b/lib/db/drizzle.ts index 62fb090..22bb5e2 100644 --- a/lib/db/drizzle.ts +++ b/lib/db/drizzle.ts @@ -68,32 +68,9 @@ async function executeProxyQueryUntraced( async function executeProxyBatch( queries: BatchQuery[], ): Promise { - return withSpan( - "db.batch", - { - "db.system": "sqlite", - "db.operation": "batch", - "db.statement_count": queries.length, - }, - async (span) => { - const operations = [ - ...new Set(queries.map((query) => sqlOperation(query.sql))), - ]; - const collections = [ - ...new Set( - queries.map((query) => sqlCollection(query.sql)).filter(Boolean), - ), - ]; - - if (operations.length === 1 && operations[0]) { - span?.setAttribute("db.operation", operations[0]); - } - if (collections.length === 1 && collections[0]) { - span?.setAttribute("db.collection", collections[0]); - } - - return executeProxyBatchUntraced(queries); - }, + return withDbBatchSpan( + queries.map((query) => query.sql), + () => executeProxyBatchUntraced(queries), ); } @@ -145,14 +122,9 @@ function instrumentLibsqlClient(client: LibsqlClient): LibsqlClient { instrumented.batch = (...args: unknown[]) => { const statements = Array.isArray(args[0]) ? args[0] : []; - return withSpan( - "db.batch", - { - "db.system": "sqlite", - "db.operation": "batch", - "db.statement_count": statements.length, - }, - async () => batch(...args), + return withDbBatchSpan( + statements.map((statement) => sqlFromStatement(statement)), + () => batch(...args), ); }; @@ -169,11 +141,7 @@ function withDbSpan( return withSpan( "db.query", - { - "db.system": "sqlite", - "db.operation": operation, - ...(collection ? { "db.collection": collection } : {}), - }, + dbQueryAttributes(operation, collection, method), async (span) => { if (await shouldInjectDbLatencySpike(operation, collection)) { span?.setAttribute("app.result", "latency_spike"); @@ -185,6 +153,44 @@ function withDbSpan( ); } +function withDbBatchSpan( + sqlStatements: (string | undefined)[], + fn: () => Promise, +) { + return withSpan("db.batch", dbBatchAttributes(sqlStatements), async () => + fn(), + ); +} + +function dbQueryAttributes( + operation: string, + collection: string | undefined, + method: string, +) { + return { + "db.system": "sqlite", + "db.operation": operation, + "db.query.method": method, + ...(collection ? { "db.collection": collection } : {}), + }; +} + +function dbBatchAttributes(sqlStatements: (string | undefined)[]) { + const operations = [ + ...new Set(sqlStatements.map(sqlOperation).filter(isString)), + ]; + const collections = [ + ...new Set(sqlStatements.map(sqlCollection).filter(isString)), + ]; + + return { + "db.system": "sqlite", + "db.operation": operations.length === 1 ? operations[0] : "batch", + "db.statement_count": sqlStatements.length, + ...(collections.length === 1 ? { "db.collection": collections[0] } : {}), + }; +} + async function shouldInjectDbLatencySpike( operation: string, collection: string | undefined, @@ -242,6 +248,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function isString(value: string | undefined): value is string { + return typeof value === "string"; +} + function createDatabase() { if (databaseConfig.mode === "proxy") { return drizzleProxy(executeProxyQuery, executeProxyBatch, { schema }); diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 9473b9c..1e88f16 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,7 +1,6 @@ import { and, desc, eq, isNull } from "drizzle-orm"; import { cookies } from "next/headers"; import { verifyToken } from "@/lib/auth/session"; -import { withSpan } from "@/lib/telemetry"; import { db } from "./drizzle"; import { activityLogs, @@ -12,56 +11,31 @@ import { } from "./schema"; export async function getUser() { - return withSpan( - "auth.user.lookup", - { "app.operation": "auth.user.lookup" }, - async (span) => { - const sessionCookie = (await cookies()).get("session"); - if (!sessionCookie?.value) { - span?.setAttribute("app.authenticated", false); - span?.setAttribute("app.result", "session_missing"); - - return null; - } - - const sessionData = await withSpan( - "auth.session.verify", - { "app.operation": "auth.session.verify" }, - async () => verifyToken(sessionCookie.value), - ); - if (!sessionData?.user || typeof sessionData.user.id !== "number") { - span?.setAttribute("app.authenticated", false); - span?.setAttribute("app.result", "session_invalid"); - - return null; - } - - if (new Date(sessionData.expires) < new Date()) { - span?.setAttribute("app.authenticated", false); - span?.setAttribute("app.result", "session_expired"); - - return null; - } - - const user = await db - .select() - .from(users) - .where(and(eq(users.id, sessionData.user.id), isNull(users.deletedAt))) - .limit(1); - - if (user.length === 0) { - span?.setAttribute("app.authenticated", false); - span?.setAttribute("app.result", "user_missing"); - - return null; - } - - span?.setAttribute("app.authenticated", true); - span?.setAttribute("app.result", "user_found"); - - return user[0]; - }, - ); + const sessionCookie = (await cookies()).get("session"); + if (!sessionCookie?.value) { + return null; + } + + const sessionData = await verifyToken(sessionCookie.value); + if (!sessionData?.user || typeof sessionData.user.id !== "number") { + return null; + } + + if (new Date(sessionData.expires) < new Date()) { + return null; + } + + const user = await db + .select() + .from(users) + .where(and(eq(users.id, sessionData.user.id), isNull(users.deletedAt))) + .limit(1); + + if (user.length === 0) { + return null; + } + + return user[0]; } export async function updateTeamSubscription( @@ -71,22 +45,13 @@ export async function updateTeamSubscription( subscriptionStatus: string; }, ) { - await withSpan( - "team.subscription.update", - { - "app.operation": "team.subscription.update", - "subscription.status": subscriptionData.subscriptionStatus, - "subscription.plan": subscriptionData.planName ?? "none", - }, - async () => - db - .update(teams) - .set({ - ...subscriptionData, - updatedAt: new Date(), - }) - .where(eq(teams.id, teamId)), - ); + await db + .update(teams) + .set({ + ...subscriptionData, + updatedAt: new Date(), + }) + .where(eq(teams.id, teamId)); } export async function getUserWithTeam(userId: number) { @@ -104,85 +69,61 @@ export async function getUserWithTeam(userId: number) { } export async function getActivityLogs() { - return withSpan( - "activity.logs.get", - { "app.operation": "activity.logs.get" }, - async (span) => { - const user = await getUser(); - if (!user) { - span?.setAttribute("app.authenticated", false); - throw new Error("User not authenticated"); - } - - const logs = await db - .select({ - id: activityLogs.id, - action: activityLogs.action, - timestamp: activityLogs.timestamp, - ipAddress: activityLogs.ipAddress, - userName: users.name, - }) - .from(activityLogs) - .leftJoin(users, eq(activityLogs.userId, users.id)) - .where(eq(activityLogs.userId, user.id)) - .orderBy(desc(activityLogs.timestamp)) - .limit(10); - - span?.setAttribute("activity.count", logs.length); - - return logs; - }, - ); + const user = await getUser(); + if (!user) { + throw new Error("User not authenticated"); + } + + return await db + .select({ + id: activityLogs.id, + action: activityLogs.action, + timestamp: activityLogs.timestamp, + ipAddress: activityLogs.ipAddress, + userName: users.name, + }) + .from(activityLogs) + .leftJoin(users, eq(activityLogs.userId, users.id)) + .where(eq(activityLogs.userId, user.id)) + .orderBy(desc(activityLogs.timestamp)) + .limit(10); } export async function getTeamForUser() { - return withSpan( - "team.for_user.get", - { "app.operation": "team.for_user.get" }, - async (span) => { - const user = await getUser(); - if (!user) { - span?.setAttribute("app.authenticated", false); - - return null; - } - - const result = await db.query.teamMembers.findFirst({ - where: eq(teamMembers.userId, user.id), + const user = await getUser(); + if (!user) { + return null; + } + + const result = await db.query.teamMembers.findFirst({ + where: eq(teamMembers.userId, user.id), + with: { + team: { with: { - team: { + teamMembers: { with: { - teamMembers: { - with: { - user: { - columns: { - id: true, - name: true, - email: true, - }, - }, + user: { + columns: { + id: true, + name: true, + email: true, }, }, }, }, }, - }); - - if (!result?.team) { - span?.setAttribute("app.result", "team_missing"); - - return null; - } + }, + }, + }); - span?.setAttribute("app.result", "team_found"); - span?.setAttribute("team.member_count", result.team.teamMembers.length); + if (!result?.team) { + return null; + } - return { - ...result.team, - teamMembers: [...result.team.teamMembers].sort(compareTeamMembers), - }; - }, - ); + return { + ...result.team, + teamMembers: [...result.team.teamMembers].sort(compareTeamMembers), + }; } function compareTeamMembers( diff --git a/lib/payments/actions.ts b/lib/payments/actions.ts index dd7a429..e782741 100644 --- a/lib/payments/actions.ts +++ b/lib/payments/actions.ts @@ -7,7 +7,6 @@ import { db } from "@/lib/db/drizzle"; import { getUser } from "@/lib/db/queries"; import { payments, teamMembers, teams } from "@/lib/db/schema"; import { isFaultActive } from "@/lib/faults"; -import { withActionSpan, withSpan } from "@/lib/telemetry"; const paymentSchema = z.object({ cardNumber: z @@ -32,109 +31,76 @@ const paymentSchema = z.object({ }); export async function processPayment(formData: FormData) { - return withActionSpan("process_payment", async (span) => { - const user = await getUser(); - if (!user) { - span?.setAttribute("app.authenticated", false); - redirect("/sign-in?redirect=pricing"); - } + const user = await getUser(); + if (!user) { + redirect("/sign-in?redirect=pricing"); + } - // Get user's team - const userTeam = await db - .select({ - teamId: teamMembers.teamId, - }) - .from(teamMembers) - .where(eq(teamMembers.userId, user.id)) - .limit(1); + // Get user's team + const userTeam = await db + .select({ + teamId: teamMembers.teamId, + }) + .from(teamMembers) + .where(eq(teamMembers.userId, user.id)) + .limit(1); - if (userTeam.length === 0) { - span?.setAttribute("app.result", "team_missing"); - throw new Error("User is not associated with any team"); - } + if (userTeam.length === 0) { + throw new Error("User is not associated with any team"); + } - const data = { - cardNumber: formData.get("cardNumber") as string, - cardHolderName: formData.get("cardHolderName") as string, - expiryDate: formData.get("expiryDate") as string, - cvv: formData.get("cvv") as string, - billingAddress: formData.get("billingAddress") as string, - city: formData.get("city") as string, - state: formData.get("state") as string, - zipCode: formData.get("zipCode") as string, - country: formData.get("country") as string, - planName: formData.get("planName") as string, - amount: Number(formData.get("amount")), - }; + const data = { + cardNumber: formData.get("cardNumber") as string, + cardHolderName: formData.get("cardHolderName") as string, + expiryDate: formData.get("expiryDate") as string, + cvv: formData.get("cvv") as string, + billingAddress: formData.get("billingAddress") as string, + city: formData.get("city") as string, + state: formData.get("state") as string, + zipCode: formData.get("zipCode") as string, + country: formData.get("country") as string, + planName: formData.get("planName") as string, + amount: Number(formData.get("amount")), + }; - try { - const validatedData = paymentSchema.parse(data); - if (await isFaultActive("payment-server-error")) { - throw new Error("Fault injected: payment-server-error"); - } - const paymentData = { - teamId: userTeam[0].teamId, - ...validatedData, - amount: (await isFaultActive("payment-wrong-amount")) - ? 800 - : validatedData.amount, - }; + try { + const validatedData = paymentSchema.parse(data); + if (await isFaultActive("payment-server-error")) { + throw new Error("Fault injected: payment-server-error"); + } + const paymentData = { + teamId: userTeam[0].teamId, + ...validatedData, + amount: (await isFaultActive("payment-wrong-amount")) + ? 800 + : validatedData.amount, + }; - if (!(await isFaultActive("payment-row-missing"))) { - await withSpan( - "payment.recorded", - { - "app.operation": "payment.recorded", - "payment.plan": paymentData.planName, - "payment.amount": paymentData.amount, - "payment.currency": "USD", - }, - async () => db.insert(payments).values(paymentData), - ); - if (await isFaultActive("payment-duplicate-charge")) { - await withSpan( - "payment.recorded", - { - "app.operation": "payment.recorded", - "payment.plan": paymentData.planName, - "payment.amount": paymentData.amount, - "payment.currency": "USD", - }, - async () => db.insert(payments).values(paymentData), - ); - } + if (!(await isFaultActive("payment-row-missing"))) { + await db.insert(payments).values(paymentData); + if (await isFaultActive("payment-duplicate-charge")) { + await db.insert(payments).values(paymentData); } + } - if (!(await isFaultActive("payment-subscription-update-skipped"))) { - await withSpan( - "team.subscription.updated", - { - "app.operation": "team.subscription.updated", - "subscription.plan": validatedData.planName, - "subscription.status": "active", - }, - async () => - db - .update(teams) - .set({ - planName: validatedData.planName, - subscriptionStatus: "active", - updatedAt: new Date(), - }) - .where(eq(teams.id, userTeam[0].teamId)), - ); - } + if (!(await isFaultActive("payment-subscription-update-skipped"))) { + await db + .update(teams) + .set({ + planName: validatedData.planName, + subscriptionStatus: "active", + updatedAt: new Date(), + }) + .where(eq(teams.id, userTeam[0].teamId)); + } - span?.setAttribute("app.result", "payment_processed"); - redirect("/dashboard?payment=success"); - } catch (error) { - span?.setAttribute("app.result", "error"); - if (error instanceof z.ZodError) { - throw new Error(error.issues.map((e) => e.message).join(", ")); - } - throw error; + redirect("/dashboard?payment=success"); + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error(error.issues.map((e) => e.message).join(", ")); } - }); + throw error; + } } export async function checkoutAction(formData: FormData) { diff --git a/lib/telemetry.ts b/lib/telemetry.ts index 18b150c..9f5c059 100644 --- a/lib/telemetry.ts +++ b/lib/telemetry.ts @@ -37,18 +37,3 @@ export async function withSpan( } }); } - -export async function withActionSpan( - operation: string, - fn: (span?: Span) => Promise, -) { - return withSpan( - `action.${operation}`, - { "app.operation": `action.${operation}` }, - fn, - ); -} - -export function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} From 29e3d5181e36d0300c710b1aa271a99e49c1d06d Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Wed, 24 Jun 2026 12:57:42 +0200 Subject: [PATCH 3/3] remove plan --- fault-injection-telemetry-plan.md | 663 ------------------------------ 1 file changed, 663 deletions(-) delete mode 100644 fault-injection-telemetry-plan.md diff --git a/fault-injection-telemetry-plan.md b/fault-injection-telemetry-plan.md deleted file mode 100644 index acee994..0000000 --- a/fault-injection-telemetry-plan.md +++ /dev/null @@ -1,663 +0,0 @@ -# Fault Injection Telemetry Plan - -This plan defines the next fault-injection work for this Playwright tutorial repository. The goal is not to implement invariant discovery yet. The goal is to build a stronger suite of normal product tests plus opt-in faults that can produce differentiated Playwright, browser, network, server, and data-layer telemetry. - -## Goal - -Create deterministic failing Playwright runs that are useful for testing generic invariant discovery over test traces. - -The fault suite should produce failures where the final Playwright symptom is often late, while the earlier divergence is visible in trace data such as: - -- browser page errors -- console messages -- resource loads -- network requests and responses -- redirects -- server actions -- API routes -- database reads and writes -- authentication/session behavior -- product state transitions - -## Non-Goals - -- Do not implement invariant discovery in this repository yet. -- Do not create a separate fault-only test suite. -- Do not make normal PR or main test runs require special flags. -- Support one named fault per run; single-fault runs are sufficient. -- Do not add many status-code variants unless they create meaningfully different telemetry. -- Do not add flaky or seeded faults until deterministic examples are working well. - -## Activation Model - -Faults are activated one at a time: - -```bash -pnpm playwright test -FAULTS=script-chunk-404 pnpm playwright test tests/change-password.spec.ts -FAULTS=payment-subscription-update-skipped pnpm playwright test tests/plan-upgrade.spec.ts -``` - -`FAULTS` supports: - -- unset or empty: no active fault; the normal suite should pass. -- a single fault name: activate that named fault only. - -The implementation should optimize for targeted single-fault runs. - -## Test Structure Rules - -Tests that participate in fault injection should continue importing the local fixture wrapper: - -```ts -import { expect, test } from "./test"; -``` - -Tests that need a fresh user should continue using `testWithNewUser`, which extends the same base fixture. - -The test bodies should remain product journeys. Fault behavior belongs in: - -```text -tests/test.ts -tests/support/faults.ts -lib/faults.ts -app routes / server actions only when server-side behavior is required -``` - -Every participating test must pass normally when `FAULTS` is unset. - -## Fault Design Principles - -- One fault name should represent one clear abnormal behavior. -- Faults should be deterministic unless the fault name explicitly describes a flaky scenario. -- Prefer real product journeys over synthetic pages. -- Prefer Playwright-layer faults when they produce enough telemetry. -- Add app-level fault gates only when the divergence must occur in server-side code or data state. -- The earliest abnormal event should usually be different from the final Playwright assertion failure. -- Fault names should describe the behavior, not the implementation mechanism. -- Avoid stacking multiple faults on one test target. - -## Invariant Coverage Targets - -The fault suite should intentionally cover these generic invariant shapes: - -| Invariant Shape | Desired Fault Examples | -| --- | --- | -| presence | expected request, log, DB write, resource, or UI event is missing | -| count range | duplicate payment/member/activity/retry, missing row, extra row | -| numeric range | latency spike, timeout, unusually long server action | -| attribute distribution | status code, role, amount, plan, action type, redirect target changes | -| edge existence | action succeeds but related DB write, redirect, refetch, or log is missing | -| edge count range | one action creates too many related rows or requests | -| relative order | activity entries or request/action sequence appears in wrong order | -| conditional eventuality | after trigger A, expected B does not appear within the learned window | - -## Fault Categories - -Use both product-level and infrastructure-level faults. The invariant system should eventually learn across both without needing domain-specific rules. - -### Infrastructure Faults - -Infrastructure faults simulate conditions around the app rather than business logic defects. - -Useful telemetry surfaces: - -- resource request failures -- hydration failures -- browser exceptions -- network latency -- auth middleware redirects -- session cookie mutation -- server-render latency -- API contract breakage - -### Product/Data Faults - -Product faults simulate incorrect application behavior or inconsistent side effects. - -Useful telemetry surfaces: - -- server action completion -- DB rows inserted, skipped, duplicated, or updated incorrectly -- activity log side effects -- payment side effects -- invitation/member relationships -- user/session state transitions - -## Existing Faults - -| Fault | Keep? | Notes | -| --- | --- | --- | -| `api-team-500` | Yes | Good simple HTTP status and API response-distribution example. | -| `api-team-malformed-json` | Maybe | Useful parser/client contract example, but overlaps with API failure unless page errors are captured. | -| `script-404` | Replace or refine | Prefer `script-chunk-404` targeted at a deterministic app script/chunk. | -| `unexpected-dashboard-redirect` | Replace | Prefer auth/session faults that produce the redirect through real middleware behavior. | -| `activity-missing-create-team` | Yes | Good presence/count failure on server-rendered product data. | -| `payment-server-error` | Yes | Good backend exception example, but less valuable than partial side-effect faults. | -| `invite-accepted-but-member-missing` | Yes | Strong edge-existence example: invite acceptance without membership relation. | - -## Priority Fault Backlog - -### 1. `script-chunk-404` - -Target test: `tests/change-password.spec.ts` or `tests/plan-upgrade.spec.ts` - -Layer: infrastructure/frontend resource - -Method: - -- In `tests/support/faults.ts`, intercept one deterministic script request. -- Return HTTP 404 with `x-fault-injected: script-chunk-404`. -- Avoid intercepting every script if possible, because broad interception can obscure the useful failure point. - -Expected telemetry: - -- resource response with status 404 -- possible hydration failure -- possible console/page error -- later interaction failure or timeout - -Expected final failure: - -- button/link interaction cannot complete, or final URL/UI assertion times out. - -Invariant coverage: - -- presence -- attribute distribution -- conditional eventuality - -### 2. `script-chunk-timeout` - -Target test: `tests/activity-section.spec.ts` or `tests/plan-upgrade.spec.ts` - -Layer: infrastructure/frontend resource timing - -Method: - -- Intercept one deterministic script request. -- Delay long enough that hydration or interaction-dependent behavior exceeds the relevant Playwright expectation timeout. -- Prefer a bounded delay over an indefinitely hanging route so the test remains debuggable. - -Expected telemetry: - -- script request duration outside normal numeric range -- delayed or absent hydration-dependent actions -- final selector or navigation timeout - -Expected final failure: - -- navigation click, form submission, or hydrated control interaction fails. - -Invariant coverage: - -- numeric range -- conditional eventuality -- order - -### 3. `runtime-error-after-hydration` - -Target test: any dashboard journey, preferably `tests/activity-section.spec.ts` - -Layer: infrastructure/browser runtime - -Method: - -- Inject a browser-side script after initial page load that throws an error. -- The test should still proceed until a normal product assertion fails. -- Do not add hidden fixture assertions for the page error. - -Expected telemetry: - -- `pageerror` event -- console/error event -- later product assertion failure - -Expected final failure: - -- a normal UI assertion fails after the page error. - -Invariant coverage: - -- presence -- order -- conditional eventuality - -### 4. `api-team-latency-spike` - -Target test: `tests/change-name.spec.ts` - -Layer: infrastructure/network timing - -Method: - -- Intercept `**/api/team`. -- Delay before continuing or fulfilling. -- Keep status/body otherwise normal if possible. - -Expected telemetry: - -- `/api/team` duration outside learned range -- normal account-update success occurs earlier -- team-member UI never refreshes before timeout - -Expected final failure: - -- `John Doe` team-member assertion times out. - -Invariant coverage: - -- numeric range -- conditional eventuality - -### 5. `api-user-malformed-json` - -Target test: `tests/change-email.spec.ts` - -Layer: infrastructure/API contract - -Method: - -- Intercept or app-gate `/api/user` to return HTTP 200 with invalid JSON. -- Use this where `/api/user` feeds visible user state, such as the general settings form or user menu. - -Expected telemetry: - -- successful HTTP status with invalid response body -- client parse exception -- missing user-dependent UI state - -Expected final failure: - -- expected email input value or authenticated UI assertion fails. - -Invariant coverage: - -- attribute distribution -- presence -- conditional eventuality - -### 6. `session-cookie-invalid-on-dashboard` - -Target test: `tests/activity-section.spec.ts` - -Layer: infrastructure/auth/session - -Method: - -- Before visiting `/dashboard`, overwrite the `session` cookie with an invalid value. -- Let `proxy.ts` and normal auth/session code handle the result. - -Expected telemetry: - -- invalid JWT verification path -- session cookie deletion or replacement -- redirect to `/sign-in` -- missing dashboard route/page events - -Expected final failure: - -- dashboard URL or `Team Settings` heading assertion fails. - -Invariant coverage: - -- attribute distribution -- edge existence -- conditional eventuality - -### 7. `session-cookie-missing-mid-flow` - -Target test: `tests/plan-upgrade.spec.ts` or `tests/change-password.spec.ts` - -Layer: infrastructure/auth/session - -Method: - -- Allow the initial authenticated page to load. -- Clear the `session` cookie before an authenticated server action. -- The product action should fail through normal auth behavior. - -Expected telemetry: - -- initial authenticated route succeeds -- later server action/API has unauthenticated behavior -- redirect, thrown action error, or missing success side effect - -Expected final failure: - -- payment success, password success, or dashboard assertion fails. - -Invariant coverage: - -- order -- conditional eventuality -- edge existence - -### 8. `account-update-db-write-skipped` - -Target test: `tests/change-name.spec.ts` - -Layer: product/data side effect - -Method: - -- Add an app-level fault gate in `updateAccount`. -- Return the normal success state and still log activity if desired. -- Skip the `users` row update. - -Expected telemetry: - -- server action completes successfully -- activity log may exist -- `/api/team` and `/api/user` still expose old user data - -Expected final failure: - -- `John Doe` does not appear in the team member list. - -Invariant coverage: - -- edge existence -- conditional eventuality - -### 9. `password-hash-update-skipped` - -Target test: `tests/change-password.spec.ts` - -Layer: product/data side effect - -Method: - -- Add an app-level fault gate in `updatePassword`. -- Return `Password updated successfully.` but skip updating `users.passwordHash`. -- Optionally still write the `UPDATE_PASSWORD` activity log to create a stronger partial-side-effect trace. - -Expected telemetry: - -- update action returns success -- password hash DB update missing -- sign-out succeeds -- sign-in with new password fails - -Expected final failure: - -- final dashboard URL or heading assertion after re-login fails. - -Invariant coverage: - -- edge existence -- conditional eventuality - -### 10. `payment-subscription-update-skipped` - -Target test: `tests/plan-upgrade.spec.ts` - -Layer: product/data side effect - -Method: - -- In `processPayment`, insert the payment row. -- Skip the `teams` subscription update. -- Still redirect to `/dashboard?payment=success`. - -Expected telemetry: - -- payment insert exists -- subscription update missing -- success redirect occurs -- dashboard refetch shows Free plan - -Expected final failure: - -- dashboard still shows `Current Plan: Free` or lacks `Billed monthly`. - -Invariant coverage: - -- edge existence -- attribute distribution -- conditional eventuality - -### 11. `invite-role-drift` - -Target test: `tests/team-invitation.spec.ts` - -Layer: product/data attribute drift - -Method: - -- During invited signup, insert the team member with a role different from the invitation role. -- Keep signup and membership creation otherwise successful. - -Expected telemetry: - -- invitation accepted -- team member relationship exists -- role attribute differs from normal distribution - -Expected final failure: - -- expected `member` role assertion fails. - -Invariant coverage: - -- attribute distribution -- edge existence - -## New Normal Tests To Add - -These tests should be added as normal passing product journeys before attaching faults. - -### `tests/activity-after-account-update.spec.ts` - -Normal journey: - -- create fresh user -- navigate to general settings -- update account name -- navigate to activity page -- assert `You updated your account` appears - -Faults enabled later: - -- `activity-update-log-missing` -- `activity-update-log-mislabelled` - -Why this test matters: - -- Creates a clean action-to-activity conditional eventuality. -- Gives the invariant miner an expected server action to DB activity edge. - -### `tests/activity-order.spec.ts` - -Normal journey: - -- create fresh user -- perform two visible actions, such as account update then password update -- navigate to activity page -- assert the newest expected activity appears before the older expected activity - -Faults enabled later: - -- `activity-order-inverted` -- `activity-timestamp-drift` - -Why this test matters: - -- Produces relative-order coverage. -- Produces timestamp/order telemetry without relying only on final selector absence. - -### `tests/payment-history.spec.ts` - -Normal journey: - -- create fresh user -- complete Plus checkout -- call authenticated `/api/payment` from the page context or add UI if needed -- assert exactly one payment row for the current team with amount `1200`, currency `USD`, and plan `Plus` - -Faults enabled later: - -- `payment-duplicate-charge` -- `payment-wrong-amount` -- `payment-row-missing` - -Why this test matters: - -- Produces count-range and attribute-distribution coverage. -- Differentiates successful redirect from correct persisted payment state. - -### `tests/signout-session.spec.ts` - -Normal journey: - -- create fresh user -- visit dashboard -- sign out from the user menu -- assert signed-out home/header state -- navigate directly to `/dashboard` -- assert redirect to `/sign-in` - -Faults enabled later: - -- `signout-cookie-not-cleared` -- `signout-activity-log-missing` - -Why this test matters: - -- Produces auth/session and redirect telemetry through a normal product journey. - -### `tests/duplicate-invite.spec.ts` - -Normal journey: - -- create fresh user -- invite an email -- invite the same email again -- assert duplicate invitation is rejected with the existing product error - -Faults enabled later: - -- `duplicate-pending-invite-allowed` - -Why this test matters: - -- Produces uniqueness/count coverage around invitations. - -## Second-Wave Faults - -Implement after the priority backlog and new normal tests are stable. - -| Fault | Test | Layer | Expected Divergence | -| --- | --- | --- | --- | -| `activity-update-log-missing` | `activity-after-account-update.spec.ts` | product/data | account update succeeds but `UPDATE_ACCOUNT` activity row is missing | -| `activity-update-log-mislabelled` | `activity-after-account-update.spec.ts` | product/data | activity row exists with wrong action attribute | -| `activity-order-inverted` | `activity-order.spec.ts` | product/data | activity timestamps/order are reversed | -| `activity-timestamp-drift` | `activity-order.spec.ts` | product/data | one activity timestamp is outside normal relative timing | -| `payment-duplicate-charge` | `payment-history.spec.ts` | product/data | one checkout creates two payment rows | -| `payment-wrong-amount` | `payment-history.spec.ts` | product/data | payment row has unexpected amount or plan | -| `payment-row-missing` | `payment-history.spec.ts` | product/data | subscription update succeeds but payment row is absent | -| `signout-cookie-not-cleared` | `signout-session.spec.ts` | product/session | sign-out action runs but session remains valid | -| `signout-activity-log-missing` | `signout-session.spec.ts` | product/data | sign-out succeeds but activity log is missing | -| `duplicate-pending-invite-allowed` | `duplicate-invite.spec.ts` | product/data | second identical pending invitation is created | - -## Implementation Steps - -### Step 1: Simplify Fault Activation Semantics - -- Keep `FAULTS` as a single fault-name string. -- Do not add multi-fault activation unless a concrete need appears. -- Update `tests/support/faults.ts` so each implemented fault only activates for its intended test title. -- Keep app-level `isFaultActive(faultName)` as exact single-name matching unless there is a concrete need to change it. - -### Step 2: Refine Existing Infrastructure Faults - -- Replace broad `script-404` behavior with targeted `script-chunk-404`. -- Add `script-chunk-timeout`. -- Add `runtime-error-after-hydration`. -- Verify these produce resource/page-error telemetry before the final assertion failure. - -### Step 3: Add Auth/Session Infrastructure Faults - -- Add `session-cookie-invalid-on-dashboard` in the Playwright fixture layer. -- Add `session-cookie-missing-mid-flow` with targeted installation for one journey. -- Prefer real middleware/auth behavior over synthetic redirect fulfillment. -- Replace or de-emphasize `unexpected-dashboard-redirect`. - -### Step 4: Add Product/Data Side-Effect Faults To Existing Tests - -- Add `account-update-db-write-skipped` in `updateAccount`. -- Add `password-hash-update-skipped` in `updatePassword`. -- Add `payment-subscription-update-skipped` in `processPayment`. -- Add `invite-role-drift` in invitation signup/member insertion. - -### Step 5: Add New Normal Product Tests - -- Add `activity-after-account-update.spec.ts`. -- Add `activity-order.spec.ts` if ordering can be asserted reliably. -- Add `payment-history.spec.ts` if payment rows can be queried in a test-scoped way. -- Add `signout-session.spec.ts`. -- Add `duplicate-invite.spec.ts`. - -Each test should pass without faults before any fault is attached. - -### Step 6: Add Second-Wave Faults To New Tests - -- Add activity log missing/mislabelled/order/timestamp faults. -- Add payment duplicate/wrong-amount/missing-row faults. -- Add sign-out cookie/activity faults. -- Add duplicate-invite acceptance fault. - -### Step 7: Verification Commands - -Normal suite: - -```bash -pnpm playwright test -``` - -Representative single-fault runs: - -```bash -FAULTS=script-chunk-404 pnpm playwright test tests/change-password.spec.ts -FAULTS=session-cookie-invalid-on-dashboard pnpm playwright test tests/activity-section.spec.ts -FAULTS=account-update-db-write-skipped pnpm playwright test tests/change-name.spec.ts -FAULTS=password-hash-update-skipped pnpm playwright test tests/change-password.spec.ts -FAULTS=payment-subscription-update-skipped pnpm playwright test tests/plan-upgrade.spec.ts -FAULTS=invite-role-drift pnpm playwright test tests/team-invitation.spec.ts -``` - -Later new-test fault runs: - -```bash -FAULTS=activity-update-log-missing pnpm playwright test tests/activity-after-account-update.spec.ts -FAULTS=activity-order-inverted pnpm playwright test tests/activity-order.spec.ts -FAULTS=payment-duplicate-charge pnpm playwright test tests/payment-history.spec.ts -FAULTS=signout-cookie-not-cleared pnpm playwright test tests/signout-session.spec.ts -FAULTS=duplicate-pending-invite-allowed pnpm playwright test tests/duplicate-invite.spec.ts -``` - -## Success Criteria - -- `pnpm playwright test` passes with `FAULTS` unset. -- Each priority fault can be run by name and fails deterministically. -- Each fault has a distinct earliest abnormal telemetry event. -- The final Playwright failure remains a normal product assertion, not a hidden fixture assertion. -- The suite covers infrastructure failures, product data failures, and partial side-effect failures. -- The suite provides examples for presence, count range, numeric range, attribute distribution, edge existence, edge count range, order, and conditional eventuality invariants. - -## Initial Implementation Order - -Recommended order: - -1. `script-chunk-404` -2. `api-team-latency-spike` -3. `session-cookie-invalid-on-dashboard` -4. `account-update-db-write-skipped` -5. `payment-subscription-update-skipped` -6. `password-hash-update-skipped` -7. `invite-role-drift` -8. `activity-after-account-update.spec.ts` -9. `payment-history.spec.ts` -10. second-wave activity/payment/signout faults - -This order gets differentiated infrastructure and product failures quickly while avoiding new test creation until the existing fault harness has proven stable.