Skip to content

Posthog#628

Open
jthrilly wants to merge 3 commits intonextfrom
posthog
Open

Posthog#628
jthrilly wants to merge 3 commits intonextfrom
posthog

Conversation

@jthrilly
Copy link
Member

This pull request refactors analytics and error tracking throughout the codebase, replacing the legacy trackEvent function with new server/client-specific analytics utilities and direct PostHog integration. It also simplifies the handling of the installation ID and removes unused or redundant code. The changes improve consistency, reliability, and observability of analytics and error reporting.

Analytics and Error Tracking Refactor:

Installation ID Handling:

  • Simplified installation ID management by setting it directly from process.env.INSTALLATION_ID or generating it with createId(), and removed the now-unnecessary getInstallationId query and associated .env.example documentation. [1] [2] [3] [4]

Code Cleanup and Removal:

  • Removed the obsolete /api/analytics route and related code, as analytics are now handled via the new utilities and PostHog.

  • Cleaned up props and imports in settings and dashboard components to reflect the new analytics and installation ID handling. [1] [2]

These changes collectively modernize and streamline analytics and error tracking infrastructure, making it easier to maintain and extend in the future.

Make installationId optional in the preprocessed schema so it doesn't
throw when the DB row doesn't exist at build time. Extract analytics
fetching into a Suspense-wrapped component in the root layout, and
guard all analytics consumers against undefined installationId.
Copilot AI review requested due to automatic review settings February 14, 2026 11:03
@vercel
Copy link

vercel bot commented Feb 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fresco-sandbox Ready Ready Preview, Comment Feb 14, 2026 7:11pm

Request Review

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors analytics and error tracking to use direct PostHog integration, replacing the legacy trackEvent flow with server/client-specific utilities and adding PostHog/OTel instrumentation support.

Changes:

  • Introduces trackServerEvent / trackServerException utilities backed by posthog-node, and updates server routes/actions to use them.
  • Switches multiple client surfaces to use posthog-js directly and adds a PostHogIdentifier client component for identify/register + opt-in/out.
  • Adds PostHog config/rewrites and Next instrumentation files; removes legacy analytics route/module and simplifies installation ID handling.

Reviewed changes

Copilot reviewed 30 out of 31 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
fresco.config.ts Adds PostHog-related constants used across server/client analytics.
lib/analytics/* New server-side PostHog client and server tracking helpers; adds client identifier component; removes legacy lib/analytics.ts.
instrumentation.ts / instrumentation-client.ts Adds OTel log export + request error capture and initializes PostHog on the client.
next.config.ts Adds /ingest/* rewrites and optional withPostHogConfig sourcemap upload configuration.
actions/appSettings.ts Updates setup flow to set installation ID and emits analytics/log records.
package.json / pnpm-lock.yaml Removes legacy analytics dependency and adds PostHog + OTel dependencies.
queries/appSettings.ts / env.js / .env.example Removes the getInstallationId helper and related env plumbing/docs.
App/components routes/actions (multiple) Replaces trackEvent usage with PostHog capture/captureException or server tracking utilities.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 48 to 64
const installationId = await getAppSetting('installationId');

if (!installationId) return;

const ph = getPostHog();

ph.captureException(err, 'server', {
app: APP_NAME,
installationId,
request_path: request.path,
request_method: request.method,
route_path: context.routePath,
route_type: context.routeType,
render_source: context.renderSource,
});

await ph.flush();
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onRequestError currently always calls getAppSetting (Prisma) and PostHog capture/flush. Since register() already gates on process.env.NEXT_RUNTIME === 'nodejs', this handler should also guard (and ideally wrap in try/catch) to avoid throwing when running in non-node runtimes or when DB access is unavailable during instrumentation error handling.

Suggested change
const installationId = await getAppSetting('installationId');
if (!installationId) return;
const ph = getPostHog();
ph.captureException(err, 'server', {
app: APP_NAME,
installationId,
request_path: request.path,
request_method: request.method,
route_path: context.routePath,
route_type: context.routeType,
render_source: context.renderSource,
});
await ph.flush();
// Guard against non-node runtimes where process or DB/PostHog may be unavailable
if (typeof process === 'undefined' || process.env?.NEXT_RUNTIME !== 'nodejs') {
return;
}
try {
const installationId = await getAppSetting('installationId');
if (!installationId) return;
const ph = getPostHog();
ph.captureException(err, 'server', {
app: APP_NAME,
installationId,
request_path: request.path,
request_method: request.method,
route_path: context.routePath,
route_type: context.routeType,
render_source: context.renderSource,
});
await ph.flush();
} catch {
// Swallow errors from instrumentation error handling to avoid masking the original error
}

Copilot uses AI. Check for mistakes.
Comment on lines 48 to 62
const installationId = await getAppSetting('installationId');

if (!installationId) return;

const ph = getPostHog();

ph.captureException(err, 'server', {
app: APP_NAME,
installationId,
request_path: request.path,
request_method: request.method,
route_path: context.routePath,
route_type: context.routeType,
render_source: context.renderSource,
});
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ph.captureException(err, 'server', …) uses a constant distinctId, so all request errors will be attributed to the same PostHog identity. Since you already fetch installationId, consider using it as the distinctId to preserve per-installation error attribution (and keep property naming consistent, e.g. installation_id).

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +33
export const loggerProvider = new LoggerProvider({
resource: resourceFromAttributes({ 'service.name': APP_NAME }),
processors: [
new BatchLogRecordProcessor(
new OTLPLogExporter({
url: POSTHOG_HOST,
headers: {
'Authorization': `Bearer ${POSTHOG_KEY}`,
'Content-Type': 'application/json',
},
}),
),
],
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OTLPLogExporter is configured with url: POSTHOG_HOST, but OTLP/HTTP exporters typically require the full endpoint path (commonly ending with /v1/logs). As-is, log export is likely to POST to the host root and fail. Consider making the OTLP endpoint explicit/configurable (e.g. POSTHOG_OTLP_LOGS_ENDPOINT).

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +21
// Ensure only one instance is created
const posthog = posthogClient();

async function shutdownPosthog() {
await posthog.shutdown();
}

process.on('SIGTERM', shutdownPosthog);
process.on('SIGINT', shutdownPosthog);
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lib/analytics/server.ts creates a PostHog client and registers SIGINT/SIGTERM handlers at module load. In dev/HMR or serverless environments this can lead to multiple client instances / duplicate signal handlers. Consider storing the client in globalThis (similar to the Prisma pattern) and guarding signal registration so it happens only once.

Suggested change
// Ensure only one instance is created
const posthog = posthogClient();
async function shutdownPosthog() {
await posthog.shutdown();
}
process.on('SIGTERM', shutdownPosthog);
process.on('SIGINT', shutdownPosthog);
// Use a global cache to avoid creating multiple clients in dev/HMR or serverless environments
type GlobalForPosthog = typeof globalThis & {
__posthogClient?: PostHog;
__posthogSignalHandlersRegistered?: boolean;
};
const globalForPosthog = globalThis as GlobalForPosthog;
// Ensure only one instance is created per process
const posthog = globalForPosthog.__posthogClient ?? posthogClient();
if (!globalForPosthog.__posthogClient) {
globalForPosthog.__posthogClient = posthog;
}
async function shutdownPosthog() {
await posthog.shutdown();
}
// Register signal handlers only once per process
if (!globalForPosthog.__posthogSignalHandlersRegistered) {
process.on('SIGTERM', shutdownPosthog);
process.on('SIGINT', shutdownPosthog);
globalForPosthog.__posthogSignalHandlersRegistered = true;
}

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +6
export const POSTHOG_KEY = 'phc_OThPUolJumHmf142W78TKWtjoYYAxGlF0ZZmhcV7J3c';
export const POSTHOG_HOST = 'https://ph-proxy.networkcanvas.com';
export const APP_NAME = 'Fresco';
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POSTHOG_KEY is hardcoded in the repo. Even if this is a “public” client key, hardcoding it prevents per-environment configuration and makes rotation difficult. Please move PostHog key/host/name to environment variables (e.g. NEXT_PUBLIC_POSTHOG_KEY / POSTHOG_HOST) and read them via the env layer instead of committing them in source control.

Suggested change
export const POSTHOG_KEY = 'phc_OThPUolJumHmf142W78TKWtjoYYAxGlF0ZZmhcV7J3c';
export const POSTHOG_HOST = 'https://ph-proxy.networkcanvas.com';
export const APP_NAME = 'Fresco';
export const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY ?? '';
export const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://ph-proxy.networkcanvas.com';
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME ?? 'Fresco';

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +65
const installationId = process.env.INSTALLATION_ID ?? createId();
await setAppSetting('installationId', installationId);
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

completeSetup() always overwrites the stored installationId with a new createId() when process.env.INSTALLATION_ID is not set. This changes behavior from “set once if missing” to “reset every time setup is completed” (which can break continuity and make analytics harder to interpret). Consider only generating/setting a new ID when the setting is currently unset, unless an explicit env override is provided.

Suggested change
const installationId = process.env.INSTALLATION_ID ?? createId();
await setAppSetting('installationId', installationId);
const envInstallationId = process.env.INSTALLATION_ID;
let installationId: string;
if (envInstallationId) {
// Explicit override from the environment (e.g. CI/dev)
installationId = envInstallationId;
await setAppSetting('installationId', installationId);
} else {
// Only generate a new installationId if one is not already stored
const existingInstallationSetting = await prisma.appSettings.findUnique({
where: { key: 'installationId' },
});
if (existingInstallationSetting?.value) {
installationId = existingInstallationSetting.value;
} else {
installationId = createId();
await setAppSetting('installationId', installationId);
}
}

Copilot uses AI. Check for mistakes.
Comment on lines 50 to +57
"dependencies": {
"@base-ui/react": "1.2.0",
"@codaco/analytics": "8.0.0",
"@codaco/protocol-validation": "10.0.0",
"@codaco/shared-consts": "5.0.0",
"@opentelemetry/api-logs": "^0.212.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.212.0",
"@opentelemetry/resources": "^2.5.1",
"@opentelemetry/sdk-logs": "^0.212.0",
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR removes the legacy ~/lib/analytics module, but there are still Vitest suites mocking/importing ~/lib/analytics (e.g. actions/__tests__/createInterview.test.ts and app/(interview)/onboard/[protocolId]/__tests__/route.test.ts). Those tests will fail module resolution unless updated to mock the new analytics utilities (or the mocks are removed).

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +23
posthog.capture({
distinctId: installationId,
event,
properties: {
installationId,
app: APP_NAME,
...properties,
},
});
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event properties use installationId, while other parts of the PR use installation_id (e.g. PostHogIdentifier and trackServerException). Mixing keys will fragment dashboards/filters. Consider standardizing on a single property name across server + client (prefer snake_case if that’s what’s already being registered).

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +17
const installationId = await getAppSetting('installationId');

if (!installationId) return;

posthog.captureException(error, installationId, {
app: APP_NAME,
installation_id: installationId,
...additionalProperties,
});
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trackServerException can throw (e.g. DB access failure in getAppSetting), and many call sites invoke it as void trackServerException(...). If this rejects, it can surface as an unhandled promise rejection. Consider wrapping the body in a try/catch and ensuring exceptions in telemetry never throw back into app control flow.

Suggested change
const installationId = await getAppSetting('installationId');
if (!installationId) return;
posthog.captureException(error, installationId, {
app: APP_NAME,
installation_id: installationId,
...additionalProperties,
});
try {
const installationId = await getAppSetting('installationId');
if (!installationId) return;
posthog.captureException(error, installationId, {
app: APP_NAME,
installation_id: installationId,
...additionalProperties,
});
} catch (telemetryError) {
// Ensure telemetry failures never affect application control flow.
// Optionally log for debugging; avoid throwing.
// console.error('Failed to track server exception', telemetryError);
}

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated 10 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +26 to +39
return (
<div className="absolute top-10 right-10">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key="success"
className="flex items-center text-sm"
variants={labelAnimationVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<CheckIcon className="text-success mr-2" />
Error report sent.
</motion.div>
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error report is always displayed as "Error report sent" without checking if analytics are disabled or if the capture actually succeeded. This could mislead users who have disabled analytics into thinking their error reports are being sent when they're not. Consider checking the analytics opt-out state or removing the notification entirely since PostHog captures are fire-and-forget.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +31
useEffect(() => {
if (!installationId) return;

posthog.register({
app: APP_NAME,
installation_id: installationId,
});

posthog.identify(installationId);
}, [installationId]);

useEffect(() => {
if (disableAnalytics) {
posthog.opt_out_capturing();
} else {
posthog.opt_in_capturing();
}
}, [disableAnalytics]);
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PostHog client needs to be initialized before calling methods like register(), identify(), or opt_out_capturing(). Since instrumentation-client.ts is not being imported anywhere, PostHog is never initialized. You should initialize PostHog within this component before using it, or ensure the initialization happens reliably elsewhere. Consider adding initialization like posthog.init(POSTHOG_KEY, {...}) at the top of this component or in a parent component.

Copilot uses AI. Check for mistakes.
installationId,
readOnly,
}: {
installationId?: string;
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The installationId can now be null (from getAppSetting), but UpdateInstallationId expects string | undefined. TypeScript may not flag this if strict null checks aren't enabled, but this type mismatch could cause issues. Change the type to installationId?: string | null or handle the null case explicitly.

Suggested change
installationId?: string;
installationId?: string | null;

Copilot uses AI. Check for mistakes.
Comment on lines 14 to 16

function getPostHog() {
posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST });
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PostHog instance created in getPostHog() is never shut down, which could lead to unflushed events and resource leaks. Unlike the instance in lib/analytics/server.ts which has SIGTERM/SIGINT handlers, this instance in instrumentation.ts has no cleanup mechanism. Consider reusing the instance from server.ts or adding proper shutdown handling.

Suggested change
function getPostHog() {
posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST });
let posthogCleanupRegistered = false;
function registerPostHogShutdownHooks(ph: PostHog) {
if (posthogCleanupRegistered || typeof process === 'undefined') return;
posthogCleanupRegistered = true;
const shutdown = () => {
// Ensure any buffered events are flushed and resources are released
ph.shutdown();
};
// Register once handlers so we don't attempt shutdown multiple times
process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);
process.once('beforeExit', shutdown);
}
function getPostHog() {
if (!posthog) {
posthog = new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST });
registerPostHogShutdownHooks(posthog);
}

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +17
export async function trackServerException(
error: unknown,
additionalProperties?: Record<string, unknown>,
) {
const installationId = await getAppSetting('installationId');

if (!installationId) return;

posthog.captureException(error, installationId, {
app: APP_NAME,
installation_id: installationId,
...additionalProperties,
});
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trackServerException function doesn't respect the disableAnalytics setting. It should check getDisableAnalytics() and return early if analytics are disabled, ensuring user privacy preferences are honored for error reporting as well.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +9
flushAt: 1,
flushInterval: 0,
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PostHog client is configured with flushAt: 1 and flushInterval: 0, which means events are sent immediately without batching. While this ensures events are sent quickly, it may create performance issues in high-traffic scenarios by making many individual HTTP requests. Consider whether immediate flushing is necessary for all events, or if some batching would be acceptable for better performance.

Suggested change
flushAt: 1,
flushInterval: 0,

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +36
<Suspense>
<Analytics />
</Suspense>
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PostHogIdentifier component may be rendered before the PostHog client is initialized via instrumentation-client.ts. This could lead to calls to posthog.register() and posthog.identify() failing or being ignored. Consider initializing PostHog in the same component or ensuring initialization order is guaranteed.

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +11
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
ui_host: 'https://us.posthog.com',
person_profiles: 'identified_only',
capture_pageview: true,
capture_pageleave: true,
autocapture: true,
});
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instrumentation-client.ts file appears to be unused in the codebase. Next.js looks for instrumentation.ts in the project root, but there's no mechanism importing or using instrumentation-client.ts. This means the PostHog client initialization in this file is never executed, and PostHog will not be initialized on the client side. You need to either rename this to instrumentation.browser.ts (if using Next.js 15.1+) or import this module somewhere in the client-side code.

Suggested change
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
ui_host: 'https://us.posthog.com',
person_profiles: 'identified_only',
capture_pageview: true,
capture_pageleave: true,
autocapture: true,
});
let isPosthogInitialized = false;
export function initializePosthogClient(): void {
// Ensure PostHog is only initialized in the browser.
if (typeof window === 'undefined') {
return;
}
if (isPosthogInitialized) {
return;
}
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
ui_host: 'https://us.posthog.com',
person_profiles: 'identified_only',
capture_pageview: true,
capture_pageleave: true,
autocapture: true,
});
isPosthogInitialized = true;
}
export function getPosthogClient() {
initializePosthogClient();
return posthog;
}

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +23
try {
const installationId = await getAppSetting('installationId');

if (!installationId) return;

posthog.capture({
distinctId: installationId,
event,
properties: {
installationId,
app: APP_NAME,
...properties,
},
});
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trackServerEvent function doesn't respect the disableAnalytics setting. It should check getDisableAnalytics() and return early if analytics are disabled, similar to how the client-side implementation handles opt-out in PostHogIdentifier.

Copilot uses AI. Check for mistakes.
destination: 'https://ph-proxy.networkcanvas.com/:path*',
},
],
skipTrailingSlashRedirect: true,
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skipTrailingSlashRedirect configuration is set at line 68, which affects how Next.js handles trailing slashes in URLs. This setting should be inside the config object, not as a separate property. As written, this may cause the configuration to be invalid or ignored.

Copilot uses AI. Check for mistakes.
Move Prisma-dependent onRequestError logic to a separate module
and dynamically import it behind a NEXT_RUNTIME check, preventing
the bundler from including Prisma in the Edge bundle.
@github-actions
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant