PostHog event capture, Linear ticket creation, deduplication — happens automatically on failure

File Structure to Create

e2e/
  helpers/
    posthog.ts        # PostHog HTTP API calls
    linear.ts         # Linear GraphQL API calls
  fixtures/
    index.ts          # Playwright fixture wrapping each test
  teardown.ts         # Global teardown (runs after all tests)

PostHog + Linear API Calls

e2e/helpers/posthog.ts

import { v4 as uuidv4 } from "uuid";

const POSTHOG_HOST = "<https://us.i.posthog.com>";
const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY!;
const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID!;

export function createSession() {
  return {
    distinctId: `playwright-${uuidv4()}`,
    sessionId: uuidv4(),
  };
}

export async function captureEvent(
  distinctId: string,
  sessionId: string,
  event: string,
  properties: Record<string, unknown> = {}
) {
  await fetch(`${POSTHOG_HOST}/capture/`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      api_key: POSTHOG_API_KEY,
      event,
      distinct_id: distinctId,
      properties: {
        $session_id: sessionId,
        ...properties,
      },
      timestamp: new Date().toISOString(),
    }),
  });
}

export function getReplayUrl(sessionId: string): string {
  return `https://us.posthog.com/project/${POSTHOG_PROJECT_ID}/replay/${sessionId}`;
}

e2e/helpers/linear.ts

const LINEAR_API = "<https://api.linear.app/graphql>";
const LINEAR_API_KEY = process.env.LINEAR_API_KEY!;
const LINEAR_TEAM_ID = process.env.LINEAR_TEAM_ID!;
const LINEAR_LABEL_ID = process.env.LINEAR_E2E_LABEL_ID; // optional

async function linearRequest(query: string, variables: Record<string, unknown>) {
  const res = await fetch(LINEAR_API, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: LINEAR_API_KEY,
    },
    body: JSON.stringify({ query, variables }),
  });
  return res.json();
}

// Returns the URL of an existing open issue with this exact title, or null
export async function findOpenIssue(title: string): Promise<string | null> {
  const query = `
    query FindIssue($filter: IssueFilter!) {
      issues(filter: $filter, first: 1) {
        nodes { id url title }
      }
    }
  `;
  const data = await linearRequest(query, {
    filter: {
      title: { eq: title },
      team: { id: { eq: LINEAR_TEAM_ID } },
      state: { type: { nin: ["completed", "cancelled"] } },
    },
  });
  return data.data?.issues?.nodes?.[0]?.url ?? null;
}

// Adds a comment to an existing issue (used when a duplicate is found)
export async function addIssueComment(issueId: string, body: string) {
  const mutation = `
    mutation AddComment($input: CommentCreateInput!) {
      commentCreate(input: $input) {
        success
      }
    }
  `;
  await linearRequest(mutation, { input: { issueId, body } });
}

export async function createIssue({
  title,
  description,
  priority = 2,
}: {
  title: string;
  description: string;
  priority?: number; // 1=urgent 2=high 3=medium 4=low
}): Promise<{ id: string; url: string } | null> {
  const mutation = `
    mutation CreateIssue($input: IssueCreateInput!) {
      issueCreate(input: $input) {
        success
        issue { id url identifier }
      }
    }
  `;
  const data = await linearRequest(mutation, {
    input: {
      teamId: LINEAR_TEAM_ID,
      title,
      description,
      priority,
      ...(LINEAR_LABEL_ID ? { labelIds: [LINEAR_LABEL_ID] } : {}),
    },
  });
  return data.data?.issueCreate?.issue ?? null;
}

export function buildDescription(params: {
  testTitle: string;
  testFile: string;
  duration: number;
  status: string;
  errorMessage?: string;
  errorStack?: string;
  replayUrl: string;
  deploymentUrl: string;
  branch: string;
}): string {
  return `
## Automated E2E Failure

| Field | Value |
|---|---|
| Test | \`${params.testTitle}\` |
| File | \`${params.testFile}\` |
| Status | ${params.status} |
| Duration | ${params.duration}ms |
| Branch | ${params.branch} |
| Deployment | ${params.deploymentUrl} |

## PostHog Session Replay

[View session replay](${params.replayUrl})

## Error

\`\`\`
${params.errorMessage ?? "No error message captured"}
\`\`\`

\`\`\`
${params.errorStack ?? "No stack trace captured"}
\`\`\`
`.trim();
}

e2e/fixtures/index.ts

import { test as base } from "@playwright/test";
import { createSession, captureEvent, getReplayUrl } from "../helpers/posthog";
import {
  findOpenIssue,
  addIssueComment,
  createIssue,
  buildDescription,
} from "../helpers/linear";

// Only create Linear issues in CI (not during local runs)
const IS_CI = Boolean(process.env.CI);

type E2EFixtures = {
  trackedPage: ReturnType<typeof base.extend> extends { use: infer U }
    ? U
    : never;
};

export const test = base.extend({
  // Replace `page` with `trackedPage` in your tests
  trackedPage: async ({ page }, use, testInfo) => {
    const { distinctId, sessionId } = createSession();
    const deploymentUrl =
      process.env.PLAYWRIGHT_BASE_URL ?? "<http://127.0.0.1:7500>";
    const branch = process.env.GITHUB_HEAD_REF ?? "local";

    // Capture test start
    await captureEvent(distinctId, sessionId, "e2e_test_started", {
      test: testInfo.title,
      file: testInfo.file,
      deployment: deploymentUrl,
      branch,
    });

    // Track page navigations automatically
    page.on("framenavigated", async (frame) => {
      if (frame === page.mainFrame()) {
        await captureEvent(distinctId, sessionId, "$pageview", {
          $current_url: frame.url(),
        }).catch(() => {}); // never let tracking break a test
      }
    });

    await use(page);

    // Capture test result
    const failed =
      testInfo.status === "failed" || testInfo.status === "timedOut";

    await captureEvent(distinctId, sessionId, "e2e_test_finished", {
      test: testInfo.title,
      status: testInfo.status,
      duration_ms: testInfo.duration,
      failed,
    }).catch(() => {});

    if (failed && IS_CI) {
      const replayUrl = getReplayUrl(sessionId);
      const title = `[E2E] ${testInfo.title}`;
      const description = buildDescription({
        testTitle: testInfo.title,
        testFile: testInfo.file,
        duration: testInfo.duration,
        status: testInfo.status ?? "failed",
        errorMessage: testInfo.error?.message,
        errorStack: testInfo.error?.stack,
        replayUrl,
        deploymentUrl,
        branch,
      });

      try {
        const existingUrl = await findOpenIssue(title);

        if (existingUrl) {
          // Issue already exists — add a comment with the new replay link
          // so the team sees it's still failing without noise
          const issueId = existingUrl.split("/").pop()!;
          await addIssueComment(
            issueId,
            `Still failing on branch \`${branch}\`.\n\n[New session replay](${replayUrl})`
          );
        } else {
          await createIssue({ title, description });
        }
      } catch (err) {
        console.error("[e2e] Linear integration error:", err);
      }
    }
  },
});

export { expect } from "@playwright/test";

e2e/teardown.ts

import { captureEvent } from "./helpers/posthog";
import { v4 as uuidv4 } from "uuid";

async function globalTeardown() {
  // Send a summary event so you can track full suite runs in PostHog
  await captureEvent(
    `playwright-suite-${uuidv4()}`,
    uuidv4(),
    "e2e_suite_finished",
    {
      deployment: process.env.PLAYWRIGHT_BASE_URL ?? "local",
      branch: process.env.GITHUB_HEAD_REF ?? "local",
      ci: Boolean(process.env.CI),
      timestamp: new Date().toISOString(),
    }
  ).catch(() => {});
}

export default globalTeardown;

Updates to playwright.config.ts

Add two lines — the teardown file and the new env vars:

export default defineConfig({
  testDir: "./e2e",
  globalTeardown: "./e2e/teardown.ts", // <-- add this
  timeout: 60_000,
  // ... rest of your existing config unchanged
});

Updates to both workflow files

Add the new secrets to the env: block in each workflow:

env:
  CI: true
  PLAYWRIGHT_BASE_URL: ${{ github.event.deployment_status.environment_url }}
  E2E_STORE_ID: ${{ secrets.E2E_STORE_ID }}
  POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
  POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID }}
  LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
  LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
  LINEAR_E2E_LABEL_ID: ${{ secrets.LINEAR_E2E_LABEL_ID }}  # optional
  GITHUB_HEAD_REF: ${{ github.head_ref }}

GitHub Secrets to Add

Go to your repo → Settings → Secrets and variables → Actions:

Secret Where to find it
POSTHOG_API_KEY PostHog → Project Settings → Project API Key
POSTHOG_PROJECT_ID PostHog → Project Settings → Project ID (numeric)
LINEAR_API_KEY Linear → Settings → API → Personal API keys
LINEAR_TEAM_ID Linear → Settings → Team → copy the ID from the URL
LINEAR_E2E_LABEL_ID Optional — create a label called "e2e" in Linear, then get its ID via the Linear API

Update existing test files is swapping page for trackedPage and importing from your fixture:

// e2e/your-test.spec.ts
import { test, expect } from "./fixtures"; // <-- your fixture, not @playwright/test

test("user can complete checkout", async ({ trackedPage }) => {
  await trackedPage.goto("/");
  // ... rest of test unchanged, just use trackedPage instead of page
});