diff --git a/.changeset/gh-owner-camelcase.md b/.changeset/gh-owner-camelcase.md new file mode 100644 index 00000000..5dfbddd7 --- /dev/null +++ b/.changeset/gh-owner-camelcase.md @@ -0,0 +1,11 @@ +--- +"@taskless/cli": patch +--- + +Spell the telemetry property `ghOwner` rather than `gh_owner`. + +Telemetry names events in `snake_case` (`cli_run`, `cli_check_completed`) and properties in `camelCase` (`cliVersion`, `durationMs`, `errorCount`). `gh_owner` was added in the previous change with the event convention applied to a property by mistake, and it was the only property in the codebase spelled that way. + +No migration is needed for anyone reading this: the property was introduced in this same unreleased cycle, so no stable build ever emitted `gh_owner` and no saved insight can be filtering on it. + +The convention is now stated normatively in the `analytics` spec, so it can be checked rather than inferred from whichever names happen to exist. diff --git a/openspec/specs/analytics/spec.md b/openspec/specs/analytics/spec.md index 37451279..778ff629 100644 --- a/openspec/specs/analytics/spec.md +++ b/openspec/specs/analytics/spec.md @@ -275,48 +275,71 @@ and `loggedIn` (boolean). The event SHALL be emitted on both success and failure - **WHEN** a command exits with an error - **THEN** PostHog SHALL receive one `cli_run` event with `success: false` +### Requirement: Event names are snake_case and properties are camelCase + +Telemetry SHALL name events in `snake_case` and properties in `camelCase`. The two are separate namespaces with separate conventions, and neither follows the other. + +This is written down because it was already true and nobody could check it. Every event has been `snake_case` (`cli_run`, `cli_check_completed`) and every property `camelCase` (`cliVersion`, `durationMs`, `errorCount`) since telemetry was added, but with the rule unstated, a single misspelled property read as evidence about the majority rather than as the outlier it was, and a proposal to rename seven correct properties got as far as a reviewed pull request before the mistake was caught. + +PostHog's own reserved properties are spelled `$current_url` and `$lib_version`. That is a fact about PostHog's namespace and SHALL NOT be read as guidance for this project's property names. + +#### Scenario: A new event is added + +- **WHEN** a new event is captured +- **THEN** its name SHALL be `snake_case` with the `cli_` prefix + +#### Scenario: A new property is added + +- **WHEN** a new property is attached to an event or to identify +- **THEN** its name SHALL be `camelCase` + +#### Scenario: The conventions do not borrow from each other + +- **WHEN** a property's name is chosen +- **THEN** the `snake_case` spelling of surrounding event names SHALL NOT be taken as a reason to spell the property in `snake_case` + ### Requirement: Identify carries the GitHub owner -Telemetry SHALL include a `gh_owner` property on every identify and on captured events, on authenticated and anonymous runs alike. +Telemetry SHALL include a `ghOwner` property on every identify and on captured events, on authenticated and anonymous runs alike. -When a GitHub owner can be extracted from the project's git remote, `gh_owner` SHALL be that owner segment verbatim. When it cannot, for ANY reason, `gh_owner` SHALL be the literal sentinel `[unknown]` rather than being omitted, so runs with an unresolvable owner are a countable cohort instead of disappearing from aggregates. The sentinel cannot collide with a real value: GitHub owner names are limited to alphanumeric characters and hyphens, so no owner can be spelled `[unknown]`. +When a GitHub owner can be extracted from the project's git remote, `ghOwner` SHALL be that owner segment verbatim. When it cannot, for ANY reason, `ghOwner` SHALL be the literal sentinel `[unknown]` rather than being omitted, so runs with an unresolvable owner are a countable cohort instead of disappearing from aggregates. The sentinel cannot collide with a real value: GitHub owner names are limited to alphanumeric characters and hyphens, so no owner can be spelled `[unknown]`. "Any reason" includes the case where **git is not installed or not on `PATH`**. That is not one of the three no-remote populations, and it is not an error: the resolution simply cannot run. It resolves to `[unknown]` like every other unresolvable case, and SHALL NOT fail the command or surface a message. -The property SHALL be named `gh_owner` rather than `gh_org`, because the first path segment of a GitHub URL may be either an organization or a user account and the CLI does not determine which. +The property SHALL be named `ghOwner` rather than `gh_org`, because the first path segment of a GitHub URL may be either an organization or a user account and the CLI does not determine which. An unresolvable owner SHALL NOT affect the command: it is a telemetry value, not a precondition. #### Scenario: Anonymous run in a GitHub repository - **WHEN** an unauthenticated user runs any command in a repository whose `origin` is a GitHub URL -- **THEN** telemetry SHALL identify with `gh_owner` set to the owner segment of that remote +- **THEN** telemetry SHALL identify with `ghOwner` set to the owner segment of that remote #### Scenario: Authenticated run in a GitHub repository - **WHEN** an authenticated user runs any command in a repository whose `origin` is a GitHub URL -- **THEN** telemetry SHALL identify with `gh_owner` set to the owner segment of that remote +- **THEN** telemetry SHALL identify with `ghOwner` set to the owner segment of that remote #### Scenario: No GitHub owner is resolvable - **WHEN** a user runs any command in any of the three no-remote populations -- **THEN** telemetry SHALL identify with `gh_owner` set to `[unknown]` +- **THEN** telemetry SHALL identify with `ghOwner` set to `[unknown]` - **AND** the property SHALL be present rather than omitted - **AND** the command SHALL run to completion unaffected #### Scenario: git is not installed - **WHEN** a user runs any command on a host where `git` is not installed or not on `PATH` -- **THEN** telemetry SHALL identify with `gh_owner` set to `[unknown]` +- **THEN** telemetry SHALL identify with `ghOwner` set to `[unknown]` - **AND** the command SHALL run to completion, with no error surfaced for the failed resolution #### Scenario: The sentinel is distinguishable from a real owner -- **WHEN** `gh_owner` is read in analytics +- **WHEN** `ghOwner` is read in analytics - **THEN** the value `[unknown]` SHALL identify a run whose owner could not be parsed - **AND** it SHALL NOT be producible by any valid GitHub owner name #### Scenario: Owner type is not asserted -- **WHEN** `gh_owner` is recorded +- **WHEN** `ghOwner` is recorded - **THEN** the CLI SHALL NOT infer or record whether the owner is an organization or a user account diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts index 43855730..ee7dacc9 100644 --- a/packages/cli/src/telemetry.ts +++ b/packages/cli/src/telemetry.ts @@ -169,11 +169,16 @@ export async function getTelemetry(cwd?: string): Promise { // git remote rather than from the token, and is present whether or not // one was found. // - // `gh_owner`, not `gh_org`: the first path segment of a GitHub URL is an + // `ghOwner`, not `ghOrg`: the first path segment of a GitHub URL is an // organization OR a user account, and telling them apart needs an // authenticated API call an anonymous run cannot make. The name states // what is actually in hand. // + // camelCase because it is a PROPERTY. Event names are snake_case here + // (`cli_run`, `cli_check_completed`) and properties are camelCase + // (`cliVersion`, `durationMs`, `errorCount`). This shipped as `gh_owner` + // by mistake and is corrected before it reaches a stable release. + // // `[unknown]` rather than an omitted property, so runs with no resolvable // owner stay countable instead of vanishing from aggregates. Resolution // never throws, so a host with no git installed lands here like any other @@ -195,7 +200,7 @@ export async function getTelemetry(cwd?: string): Promise { cli: anonymousId, cliVersion: CLI_VERSION, scaffoldVersion, - gh_owner: ghOwner, + ghOwner, }, }); @@ -219,7 +224,7 @@ export async function getTelemetry(cwd?: string): Promise { cli: anonymousId, cliVersion: CLI_VERSION, scaffoldVersion, - gh_owner: ghOwner, + ghOwner, }, ...(!anonymous && orgSubject !== undefined ? { groups: { organization: String(orgSubject) } } diff --git a/packages/cli/test/telemetry.test.ts b/packages/cli/test/telemetry.test.ts index 698a3890..9ebf84cd 100644 --- a/packages/cli/test/telemetry.test.ts +++ b/packages/cli/test/telemetry.test.ts @@ -376,12 +376,12 @@ describe("shutdown", () => { }); }); -/** The `gh_owner` on the most recent call recorded by a posthog mock. */ +/** The `ghOwner` on the most recent call recorded by a posthog mock. */ function lastGhOwner(mock: { mock: { calls: unknown[][] } }): unknown { const call = mock.mock.calls.at(-1)?.[0] as { properties?: Record; }; - return call.properties?.gh_owner; + return call.properties?.ghOwner; } /** @@ -389,7 +389,7 @@ function lastGhOwner(mock: { mock: { calls: unknown[][] } }): unknown { * resolved from the git remote rather than the token, so it is present on * unauthenticated runs — which is the population it exists to measure. */ -describe("gh_owner", () => { +describe("ghOwner", () => { it("carries the owner on an anonymous run in a GitHub repository", async () => { const cwd = await mkdtemp(join(tmpdir(), "taskless-ghowner-")); try { @@ -442,7 +442,7 @@ describe("gh_owner", () => { const identifyCall = mockIdentify.mock.calls.at(-1)?.[0] as { properties: Record; }; - expect(identifyCall.properties).toHaveProperty("gh_owner"); + expect(identifyCall.properties).toHaveProperty("ghOwner"); expect(lastGhOwner(mockIdentify)).toBe("[unknown]"); expect(lastGhOwner(mockCapture)).toBe("[unknown]"); } finally {