Skip to content

feat(nextjs): Environment drift telemetry event for keyless applications #6522

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bright-parks-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/nextjs': patch
---

Add new telemetry event KEYLESS_ENV_DRIFT_DETECTED to detect drift between publishable and secret keys in keyless apps and values in the .env file.

This event only fires once as controlled with the .clerk/.tmp/telemetry.json file to prevent telemetry event noise
9 changes: 9 additions & 0 deletions packages/nextjs/src/app-router/server/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export async function ClerkProvider(

let output: ReactNode;

try {
const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then(
mod => mod.detectKeylessEnvDrift,
);
await detectKeylessEnvDrift();
} catch {
// ignore
}

if (shouldRunAsKeyless) {
output = (
<KeylessProvider
Expand Down
184 changes: 184 additions & 0 deletions packages/nextjs/src/server/keyless-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { TelemetryEventRaw } from '@clerk/types';
import { promises as fs } from 'fs';
import { dirname, join } from 'path';

import { createClerkClientWithOptions } from './createClerkClient';

const EVENT_KEYLESS_ENV_DRIFT_DETECTED = 'KEYLESS_ENV_DRIFT_DETECTED';
const EVENT_SAMPLING_RATE = 1; // 100% sampling rate
const TELEMETRY_FLAG_FILE = '.clerk/.tmp/telemetry.json';

type EventKeylessEnvDriftPayload = {
publicKeyMatch: boolean;
secretKeyMatch: boolean;
envVarsMissing: boolean;
keylessFileHasKeys: boolean;
keylessPublishableKey: string;
envPublishableKey: string;
};
Comment on lines +11 to +18
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Do not send raw keys; mask publishable keys and make fields optional

Raw publishable keys shouldn’t be sent in telemetry. Also, casting/forcing missing values into strings can hide absence. Make fields optional and masked, and prefer interface for an extendable payload shape per guidelines.

-type EventKeylessEnvDriftPayload = {
+interface EventKeylessEnvDriftPayload {
   publicKeyMatch: boolean;
   secretKeyMatch: boolean;
   envVarsMissing: boolean;
   keylessFileHasKeys: boolean;
-  keylessPublishableKey: string;
-  envPublishableKey: string;
-};
+  keylessPublishableKeyMasked?: string;
+  envPublishableKeyMasked?: string;
+}

Add this helper outside the shown range:

function maskKeyForTelemetry(key?: string): string | undefined {
  if (!key) return undefined;
  const head = key.slice(0, 8);
  const tail = key.slice(-4);
  return `${head}${tail}`;
}
🤖 Prompt for AI Agents
In packages/nextjs/src/server/keyless-telemetry.ts around lines 11 to 18, the
telemetry payload currently exposes raw publishable keys and forces values into
strings; change the type to an interface with optional fields (publicKeyMatch?:
boolean; secretKeyMatch?: boolean; envVarsMissing?: boolean;
keylessFileHasKeys?: boolean; keylessPublishableKey?: string;
envPublishableKey?: string) so absent values are not coerced, and remove any
code that sends raw keys; instead add the provided maskKeyForTelemetry helper
(outside this range) and set keylessPublishableKey and envPublishableKey to the
masked results (or undefined) before sending telemetry so raw keys are never
transmitted.


/**
* Gets the absolute path to the telemetry flag file.
*
* This file is used to track whether telemetry events have already been fired
* to prevent duplicate event reporting during the application lifecycle.
*
* @returns The absolute path to the telemetry flag file in the project's .clerk/.tmp directory
*/
function getTelemetryFlagFilePath(): string {
return join(process.cwd(), TELEMETRY_FLAG_FILE);
}

/**
* Attempts to create a telemetry flag file to mark that a telemetry event has been fired.
*
* This function uses the 'wx' flag to create the file atomically - it will only succeed
* if the file doesn't already exist. This ensures that telemetry events are only fired
* once per application lifecycle, preventing duplicate event reporting.
*
* @returns Promise<boolean> - Returns true if the flag file was successfully created (meaning
* the event should be fired), false if the file already exists (meaning the event was
* already fired) or if there was an error creating the file
*/
async function tryMarkTelemetryEventAsFired(): Promise<boolean> {
try {
const flagFilePath = getTelemetryFlagFilePath();
const flagDirectory = dirname(flagFilePath);

// Ensure the directory exists before attempting to write the file
await fs.mkdir(flagDirectory, { recursive: true });

const flagData = {
firedAt: new Date().toISOString(),
event: EVENT_KEYLESS_ENV_DRIFT_DETECTED,
};
await fs.writeFile(flagFilePath, JSON.stringify(flagData, null, 2), { flag: 'wx' });
return true;
} catch (error: unknown) {
if ((error as { code?: string })?.code === 'EEXIST') {
return false;
}
console.warn('Failed to create telemetry flag file:', error);
return false;
}
}

/**
* Detects and reports environment drift between keyless configuration and environment variables.
*
* This function compares the Clerk keys stored in the keyless configuration file (.clerk/clerk.json)
* with the keys set in environment variables (NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY).
* It only reports drift when there's an actual mismatch between existing keys, not when keys are simply missing.
*
* The function handles several scenarios and only reports drift in specific cases:
* - **Normal keyless mode**: env vars missing but keyless file has keys → no drift (expected)
* - **No configuration**: neither env vars nor keyless file have keys → no drift (nothing to compare)
* - **Actual drift**: env vars exist and don't match keyless file keys → drift detected
* - **Empty keyless file**: keyless file exists but has no keys → no drift (nothing to compare)
*
* Drift is only detected when:
* 1. Both environment variables and keyless file contain keys
* 2. The keys in environment variables don't match the keys in the keyless file
*
* Telemetry events are only fired once per application lifecycle using a flag file mechanism
* to prevent duplicate reporting.
*
* @returns Promise<void> - Function completes silently, errors are logged but don't throw
*/
export async function detectKeylessEnvDrift(): Promise<void> {
// Only run on server side
if (typeof window !== 'undefined') {
return;
}

try {
// Dynamically import server-side dependencies to avoid client-side issues
const { safeParseClerkFile } = await import('./keyless-node.js');

// Read the keyless configuration file
const keylessFile = safeParseClerkFile();

if (!keylessFile) {
return;
}

// Get environment variables
const envPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
const envSecretKey = process.env.CLERK_SECRET_KEY;

// Check the state of environment variables and keyless file
const hasEnvVars = Boolean(envPublishableKey || envSecretKey);
const keylessFileHasKeys = Boolean(keylessFile?.publishableKey && keylessFile?.secretKey);
const envVarsMissing = !envPublishableKey && !envSecretKey;

// Early return conditions - no drift to detect in these scenarios:
if (!hasEnvVars && !keylessFileHasKeys) {
// Neither env vars nor keyless file have keys - nothing to compare
return;
}

if (envVarsMissing && keylessFileHasKeys) {
// Environment variables are missing but keyless file has keys - this is normal for keyless mode
return;
}

if (!keylessFileHasKeys) {
// Keyless file doesn't have keys, so no drift can be detected
return;
}

// Only proceed with drift detection if we have something meaningful to compare
if (!hasEnvVars) {
return;
}

// Compare keys only when both sides have values to compare
const publicKeyMatch = Boolean(
envPublishableKey && keylessFile.publishableKey && envPublishableKey === keylessFile.publishableKey,
);

const secretKeyMatch = Boolean(envSecretKey && keylessFile.secretKey && envSecretKey === keylessFile.secretKey);

// Determine if there's an actual drift:
// Drift occurs when we have env vars that don't match the keyless file keys
const hasActualDrift =
(envPublishableKey && keylessFile.publishableKey && !publicKeyMatch) ||
(envSecretKey && keylessFile.secretKey && !secretKeyMatch);

// Only fire telemetry if there's an actual drift (not just missing keys)
if (!hasActualDrift) {
return;
}

const payload: EventKeylessEnvDriftPayload = {
publicKeyMatch,
secretKeyMatch,
envVarsMissing,
keylessFileHasKeys,
keylessPublishableKey: keylessFile.publishableKey ?? '',
envPublishableKey: envPublishableKey ?? '',
};

// Create a clerk client to access telemetry
const clerkClient = createClerkClientWithOptions({
publishableKey: keylessFile.publishableKey,
secretKey: keylessFile.secretKey,
});

const shouldFireEvent = await tryMarkTelemetryEventAsFired();

if (shouldFireEvent) {
// Fire drift detected event only if we successfully created the flag
const driftDetectedEvent: TelemetryEventRaw<EventKeylessEnvDriftPayload> = {
event: EVENT_KEYLESS_ENV_DRIFT_DETECTED,
eventSamplingRate: EVENT_SAMPLING_RATE,
payload,
};

clerkClient.telemetry?.record(driftDetectedEvent);
}
} catch (error) {
// Silently handle errors to avoid breaking the application
console.warn('Failed to detect keyless environment drift:', error);
}
}
Loading