Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/cli-posthog-build-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tinacms/cli": patch
---

Track `tinacms build` invocations + outcomes in PostHog. Replaces the `metrics.tina.io` event for this command. Opt-out via `--noTelemetry` is unchanged.
4 changes: 3 additions & 1 deletion packages/@tinacms/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"@types/node": "^22.13.1",
"@types/progress": "catalog:",
"@types/prompts": "catalog:",
"jest": "catalog:"
"jest": "catalog:",
"ts-jest": "catalog:"
},
"scripts": {
"build": "tinacms-scripts build",
Expand Down Expand Up @@ -91,6 +92,7 @@
"memory-level": "catalog:",
"minimatch": "catalog:",
"normalize-path": "catalog:",
"posthog-node": "^5.17.2",
"prettier": "catalog:",
"progress": "catalog:",
"prompts": "catalog:",
Expand Down
92 changes: 72 additions & 20 deletions packages/@tinacms/cli/src/next/commands/build-command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import crypto from 'crypto';
import path from 'path';
import { ChangeType, diff } from '@graphql-inspector/core';
import { type Database, FilesystemBridge, buildSchema } from '@tinacms/graphql';
import { Telemetry } from '@tinacms/metrics';
import { parseURL } from '@tinacms/schema-tools';
import {
type SearchClient,
Expand Down Expand Up @@ -31,6 +30,15 @@ import { BaseCommand } from '../baseCommands';
import { createDevServer } from '../dev-command/server';
import { buildProductionSpa } from './server';
import { waitForDB } from './waitForDB';
import type { PostHog } from 'posthog-node';
import {
BuildFinishedEvent,
BuildInvokeEvent,
BuildInvokeEventPayload,
generateSessionId,
initializePostHog,
postHogCapture,
} from '../../../utils/posthog';

export class BuildCommand extends BaseCommand {
static paths = [['build']];
Expand Down Expand Up @@ -82,7 +90,19 @@ export class BuildCommand extends BaseCommand {
description: `Build the CMS and autogenerated modules for usage with TinaCloud`,
});

private posthogClient: PostHog | null = null;
private buildStartedAt = 0;
private buildRunId = generateSessionId();

async catch(error: any): Promise<void> {
const errorCode =
(error as { errorCode?: string })?.errorCode ?? 'ERR_BUILD_FAILED';
postHogCapture(this.posthogClient, this.buildRunId, BuildFinishedEvent, {
success: false,
durationMs: Date.now() - this.buildStartedAt,
errorCode,
});
if (this.posthogClient) await this.posthogClient.shutdown();
console.error(error);
process.exit(1);
}
Expand Down Expand Up @@ -124,38 +144,58 @@ export class BuildCommand extends BaseCommand {
}
const localContentOnly = this.contentOption === 'local';

// Init telemetry + fire invoke BEFORE any work that can fail. From here
// on, failures throw and catch() fires a finished{success:false} event
// with the thrown error's errorCode.
this.posthogClient = this.noTelemetry
? null
: await initializePostHog(
'https://identity-v2.tinajs.io/v2/posthog-token',
false
);

const buildInvokeEventPayload: BuildInvokeEventPayload = {
hasLocalOption: Boolean(this.localOption),
hasContentLocal: localContentOnly,
skipIndexing: Boolean(this.skipIndexing),
partialReindex: Boolean(this.partialReindex),
hasPreviewName: Boolean(this.previewName),
specifiesTinaGraphQLVersions: Boolean(this.tinaGraphQLVersion),
skipCloudChecks: Boolean(this.skipCloudChecks),
skipSearchIndex: Boolean(this.skipSearchIndex),
};
this.buildStartedAt = Date.now();
postHogCapture(
this.posthogClient,
this.buildRunId,
BuildInvokeEvent,
buildInvokeEventPayload
);

try {
await configManager.processConfig();
} catch (e) {
logger.error(`\n${dangerText(e.message)}`);
logger.error(
dangerText('Unable to build, please fix your Tina config and try again')
);
process.exit(1);
throw Object.assign(new Error(e.message), {
errorCode: 'ERR_CONFIG_LOAD_FAILED',
});
}

// Track localContentPath usage so we can measure adoption of the
// multi-repo separation.
const telemetry = new Telemetry({ disabled: this.noTelemetry });
await telemetry.submitRecord({
event: {
name: 'tinacms:cli:build:invoke',
hasLocalContentPath: Boolean(configManager.config.localContentPath),
},
});
if (localContentOnly && !this.localOption) {
const config = configManager.config;
const missing = [];
if (!config.branch) missing.push('branch');
if (!config.clientId) missing.push('clientId');
if (!config.token) missing.push('token');
if (missing.length > 0) {
logger.error(
`${dangerText(
`ERROR: --content=local requires ${missing.join(', ')} to be configured, since the generated client must point to TinaCloud.`
)}`
);
process.exit(1);
const message = `--content=local requires ${missing.join(', ')} to be configured, since the generated client must point to TinaCloud.`;
logger.error(`${dangerText(`ERROR: ${message}`)}`);
throw Object.assign(new Error(message), {
errorCode: 'ERR_MISSING_CLOUD_CREDS',
});
}
}
let server: ViteDevServer | undefined;
Expand Down Expand Up @@ -210,7 +250,9 @@ export class BuildCommand extends BaseCommand {
if (this.verbose) {
console.error(e);
}
process.exit(1);
throw Object.assign(new Error(e.message), {
errorCode: 'ERR_INDEXING_FAILED',
});
}
}

Expand Down Expand Up @@ -288,7 +330,9 @@ export class BuildCommand extends BaseCommand {
if (this.verbose) {
console.error(e);
}
process.exit(1);
throw Object.assign(new Error(e.message), {
errorCode: 'ERR_CLOUD_CHECK_FAILED',
});
}
}

Expand Down Expand Up @@ -378,7 +422,9 @@ export class BuildCommand extends BaseCommand {
});
if (err) {
logger.error(`${dangerText(`ERROR: ${err.message}`)}`);
process.exit(1);
throw Object.assign(new Error(err.message), {
errorCode: 'ERR_SEARCH_INDEX_FAILED',
});
}
}

Expand Down Expand Up @@ -428,6 +474,12 @@ export class BuildCommand extends BaseCommand {
process.env.NODE_ENV = 'production';
}
}
postHogCapture(this.posthogClient, this.buildRunId, BuildFinishedEvent, {
success: true,
durationMs: Date.now() - this.buildStartedAt,
});
if (this.posthogClient) await this.posthogClient.shutdown();

if (this.subCommand) {
await this.startSubCommand();
} else {
Expand Down
69 changes: 69 additions & 0 deletions packages/@tinacms/cli/src/utils/fetchPostHogConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import fetchPostHogConfig from './fetchPostHogConfig';

describe('fetchPostHogConfig', () => {
const realFetch = globalThis.fetch;
let warnSpy: jest.SpyInstance;

beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});

afterEach(() => {
globalThis.fetch = realFetch;
warnSpy.mockRestore();
});

it('returns api_key + host on a 200 JSON response', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ api_key: 'phc_test', host: 'https://eu.example' }),
}) as unknown as typeof fetch;

expect(await fetchPostHogConfig('https://x')).toEqual({
POSTHOG_API_KEY: 'phc_test',
POSTHOG_ENDPOINT: 'https://eu.example',
});
});

it('returns {} on a non-2xx response', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
statusText: 'Bad Gateway',
json: async () => ({}),
}) as unknown as typeof fetch;

expect(await fetchPostHogConfig('https://x')).toEqual({});
});

it('returns {} on a network error', async () => {
globalThis.fetch = jest
.fn()
.mockRejectedValue(new Error('ENOTFOUND')) as unknown as typeof fetch;

expect(await fetchPostHogConfig('https://x')).toEqual({});
});

it('returns {} when JSON parse fails', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => {
throw new SyntaxError('Unexpected token');
},
}) as unknown as typeof fetch;

expect(await fetchPostHogConfig('https://x')).toEqual({});
});

it('passes an AbortSignal to fetch so offline callers do not hang', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ api_key: 'k', host: 'h' }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;

await fetchPostHogConfig('https://x');

const [, init] = fetchMock.mock.calls[0];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
33 changes: 33 additions & 0 deletions packages/@tinacms/cli/src/utils/fetchPostHogConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface PostHogConfig {
POSTHOG_API_KEY?: string;
POSTHOG_ENDPOINT?: string;
}

export default async function fetchPostHogConfig(
endpointUrl: string
): Promise<PostHogConfig> {
try {
const response = await fetch(endpointUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// Cap latency for offline / firewalled developers. Endpoint is
// typically single-digit ms when reachable; a timeout returns {}
// and disables telemetry for this run, which is the right behavior.
signal: AbortSignal.timeout(2000),
});

if (!response.ok) {
throw new Error(`Failed to fetch PostHog config: ${response.statusText}`);
}

const config = await response.json();
return {
POSTHOG_API_KEY: config.api_key,
POSTHOG_ENDPOINT: config.host,
};
} catch {
return {};
}
}
Loading
Loading