PostHog event capture, Linear ticket creation, deduplication — happens automatically on failure
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;
playwright.config.tsAdd 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
});
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 }}
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
});