Skip to content
Open
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
32 changes: 32 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,38 @@ jobs:
deployments: write
steps:
- uses: actions/checkout@v4
- name: Inject PostHog API key into marketing site
shell: bash
env:
# POSTHOG_PROJECT_KEY is a GitHub Actions repo *variable* (not a secret).
# PostHog `phc_*` project keys are ingestion-only and public by design —
# same category as a Sentry DSN or Stripe publishable key. We still gate
# on its presence so forks (which inherit neither vars nor secrets in
# fork PRs) and local previews silently ship without telemetry.
RELAYCAST_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROJECT_KEY }}
run: |
# The committed site/index.html contains a __RELAYCAST_POSTHOG_API_KEY__
# placeholder so that forks and local previews never accidentally send
# telemetry to our production PostHog project. Substitute the real key
# here when (and only when) the GitHub Actions variable is present.
if [ -z "${RELAYCAST_POSTHOG_API_KEY:-}" ]; then
echo "POSTHOG_PROJECT_KEY variable is not set; marketing site will ship without telemetry."
else
# Use a Node script to do a literal replacement (avoids shell escaping
# surprises and works even if the key ever contains regex-special chars).
node -e "
const fs = require('fs');
const path = 'site/index.html';
const key = process.env.RELAYCAST_POSTHOG_API_KEY;
const src = fs.readFileSync(path, 'utf8');
if (!src.includes('__RELAYCAST_POSTHOG_API_KEY__')) {
console.error('Placeholder __RELAYCAST_POSTHOG_API_KEY__ not found in ' + path);
process.exit(1);
}
fs.writeFileSync(path, src.split('__RELAYCAST_POSTHOG_API_KEY__').join(key));
console.log('Substituted PostHog key into ' + path);
"
fi
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 40 additions & 1 deletion packages/mcp/src/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,10 @@ describe('createMcpTelemetry', () => {
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);

const telemetry = createMcpTelemetry('1.0.0', { posthogHost: 'https://api.example.com' });
const telemetry = createMcpTelemetry('1.0.0', {
posthogHost: 'https://api.example.com',
posthogApiKey: 'phc_example',
});
telemetry.capture('relaycast_mcp_server_started', {});
await telemetry.flush();

Expand All @@ -155,6 +158,42 @@ describe('createMcpTelemetry', () => {
}
});

it('uses options.posthogApiKey when process.env is unset (Cloudflare Worker path)', async () => {
// Regression: in CF Workers, wrangler secrets only flow through the env
// bindings, not process.env. McpSessionDO threads them into createMcpTelemetry
// via the options object. Verify that path actually captures events.
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ anonymousId: 'anon-cf' }));

expect(process.env.RELAYCAST_POSTHOG_API_KEY).toBeUndefined();
expect(process.env.POSTHOG_API_KEY).toBeUndefined();

const telemetry = createMcpTelemetry('1.0.0', {
posthogHost: 'https://cf-host.example/',
posthogApiKey: 'phc_cf_worker',
});

telemetry.capture('relaycast_mcp_server_started', { transport: 'http' });
await telemetry.flush();

expect(nodeRequestMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(capturedBodies[0] ?? '{}') as { api_key: string };
expect(body.api_key).toBe('phc_cf_worker');
});

it('silently no-ops when no PostHog API key is configured', async () => {
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ anonymousId: 'anon-123' }));

// No env vars, no options.posthogApiKey — should not make any HTTP calls.
const telemetry = createMcpTelemetry('1.0.0', {
posthogHost: 'https://app.posthog.example/',
});

telemetry.capture('relaycast_mcp_server_started', { transport: 'stdio' });
await telemetry.flush();

expect(nodeRequestMock).not.toHaveBeenCalled();
});

it('flush resolves when no events are pending', async () => {
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export interface McpServerOptions {
strictAgentName?: boolean;
telemetryTransport?: 'stdio' | 'http';
telemetry?: McpTelemetry;
/** PostHog API key for MCP telemetry. Used in runtimes where process.env is unavailable (e.g. Cloudflare Workers). */
posthogApiKey?: string;
/** PostHog host for MCP telemetry. Used in runtimes where process.env is unavailable (e.g. Cloudflare Workers). */
posthogHost?: string;
/** Multi-workspace configs parsed from RELAY_WORKSPACES_JSON. */
workspaces?: McpWorkspaceConfig[];
/** Default workspace ID or alias to use as the active workspace. */
Expand Down Expand Up @@ -84,6 +88,8 @@ export function createRelayMcpServer(options: McpServerOptions): McpServer {
originSurface: mcpOrigin.surface,
originClient: mcpOrigin.client,
originVersion: mcpOrigin.version,
posthogApiKey: options.posthogApiKey,
posthogHost: options.posthogHost,
});

const mcpServer = new McpServer(
Expand Down
10 changes: 6 additions & 4 deletions packages/mcp/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,21 @@ export interface McpTelemetry {

const TELEMETRY_PATH = path.join(os.homedir(), '.relay', 'telemetry.json');
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com';
const DEFAULT_POSTHOG_PUBLIC_KEY = 'phc_OAqBdey9pESZCcwaen9Fpyz6Ez8QKiMmLOnvFknXzg4';

function isTruthy(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
}

function telemetryEnabled(state: TelemetryState): boolean {
function telemetryEnabled(state: TelemetryState, apiKey: string): boolean {
if (isTruthy(process.env.DO_NOT_TRACK) || isTruthy(process.env.RELAYCAST_TELEMETRY_DISABLED)) {
return false;
}
if (state.telemetryDisabled === true) return false;
// No API key configured -> silently no-op. This is the default for forks
// and local development; production builds inject the key via env.
if (!apiKey) return false;
return true;
}

Expand All @@ -65,7 +67,7 @@ function getPosthogApiKey(options: McpTelemetryOptions): string {
process.env.RELAYCAST_POSTHOG_API_KEY
?? process.env.POSTHOG_API_KEY
?? options.posthogApiKey
?? DEFAULT_POSTHOG_PUBLIC_KEY
?? ''
);
Comment on lines +70 to 71
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 MCP telemetry silently disabled in Cloudflare Worker production after removing hardcoded key

The removal of DEFAULT_POSTHOG_PUBLIC_KEY breaks MCP telemetry in the Cloudflare Worker context (McpSessionDO). The getPosthogApiKey function falls through process.env.RELAYCAST_POSTHOG_API_KEYprocess.env.POSTHOG_API_KEYoptions.posthogApiKey''. In Cloudflare Workers, secrets set via wrangler secret put are available through the env bindings parameter (as used by the server package at packages/server/src/lib/telemetry.ts:36), not through process.env. Since McpSessionDO (packages/server/src/durable-objects/mcpSession.ts:51-57) calls createRelayMcpServer without passing posthogApiKey, and the MCP server's createRelayMcpServer (packages/mcp/src/server.ts:83-87) doesn't pass it to createMcpTelemetry either, the API key resolves to ''. With the new !apiKey check at packages/mcp/src/telemetry.ts:47, telemetry is disabled. This silently drops all MCP-specific events (relaycast_mcp_server_started, relaycast_mcp_session_authenticated, etc.) in production. Previously, the hardcoded DEFAULT_POSTHOG_PUBLIC_KEY ensured these events were always captured.

Prompt for agents
The core issue is that the MCP package's telemetry uses process.env to read the PostHog API key, but in the Cloudflare Worker runtime (McpSessionDO), secrets are only available through the env bindings parameter, not process.env. The McpSessionDO has access to this.env.POSTHOG_API_KEY, but it never passes it through to createRelayMcpServer or createMcpTelemetry.

To fix this, you need to thread the PostHog API key from the Worker env bindings into the MCP telemetry. One approach:

1. Add a posthogApiKey field to McpServerOptions in packages/mcp/src/server.ts
2. Pass it through when creating telemetry at server.ts:83-87
3. In McpSessionDO.ensureInitialized() (packages/server/src/durable-objects/mcpSession.ts:51), pass posthogApiKey: this.env.POSTHOG_API_KEY to createRelayMcpServer

This mirrors how the server package's own telemetry (packages/server/src/lib/telemetry.ts:36) explicitly reads env.POSTHOG_API_KEY from the Cloudflare bindings rather than relying on process.env.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

Expand Down Expand Up @@ -211,7 +213,7 @@ export function createMcpTelemetry(version = 'unknown', options: McpTelemetryOpt
const originVersion = options.originVersion ?? version;

const capture = (event: ClientTelemetryEventName, properties: Record<string, unknown> = {}) => {
if (!telemetryEnabled(state)) return;
if (!telemetryEnabled(state, posthogApiKey)) return;

const parsed = parseTelemetryIngestionEvent({
event,
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/durable-objects/mcpSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export class McpSessionDO implements DurableObject {
? 'https://api.relaycast.dev'
: undefined,
telemetryTransport: 'http',
// Cloudflare Workers expose `wrangler secret put` values through the
// env bindings, not process.env, so forward them explicitly. Both are
// optional; when unset the MCP telemetry layer silently no-ops.
posthogApiKey: this.env.POSTHOG_API_KEY,
posthogHost: this.env.POSTHOG_HOST,
});

this.transport.onclose = () => {
Expand Down
2 changes: 1 addition & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Relaycast — Headless Slack for AI Agents</title>
<meta name="description" content="Give your AI agents channels, threads, DMs, and real-time events. Framework-agnostic messaging that works across any CLI, any language, any model.">
<meta name="relaycast-posthog-key" content="phc_OAqBdey9pESZCcwaen9Fpyz6Ez8QKiMmLOnvFknXzg4">
<meta name="relaycast-posthog-key" content="__RELAYCAST_POSTHOG_API_KEY__">
<meta name="relaycast-posthog-host" content="https://us.i.posthog.com">
<meta name="theme-color" content="#F9FAFB">
<link rel="preconnect" href="https://fonts.googleapis.com">
Expand Down
20 changes: 16 additions & 4 deletions site/script.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const POSTHOG_DEFAULT_HOST = 'https://us.i.posthog.com';
const POSTHOG_PUBLIC_KEY = 'phc_OAqBdey9pESZCcwaen9Fpyz6Ez8QKiMmLOnvFknXzg4';
const POSTHOG_DISTINCT_ID_KEY = 'relaycast_posthog_distinct_id';
const POSTHOG_SESSION_ID = window.crypto?.randomUUID?.() ?? String(Date.now());

Expand Down Expand Up @@ -33,11 +32,24 @@ const telemetry = (() => {
};
}

const posthogKey =
const metaKey = getMetaContent('relaycast-posthog-key');
// The meta tag ships with a `__RELAYCAST_POSTHOG_API_KEY__` placeholder that
// CI substitutes at deploy time. Treat the unsubstituted placeholder (and any
// other non-phc value) as "no key configured" so forks / local previews
// silently no-op instead of POSTing to an unknown PostHog project.
const candidateKey =
window.POSTHOG_API_KEY ||
window.RELAYCAST_POSTHOG_KEY ||
getMetaContent('relaycast-posthog-key') ||
POSTHOG_PUBLIC_KEY;
metaKey ||
'';
const posthogKey = candidateKey.startsWith('phc_') ? candidateKey : '';

if (!posthogKey) {
return {
enabled: false,
capture: () => {},
};
}

const rawHost =
window.POSTHOG_HOST || window.RELAYCAST_POSTHOG_HOST || getMetaContent('relaycast-posthog-host');
Expand Down
Loading