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
11 changes: 11 additions & 0 deletions .changeset/gh-owner-camelcase.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 32 additions & 9 deletions openspec/specs/analytics/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 8 additions & 3 deletions packages/cli/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,16 @@ export async function getTelemetry(cwd?: string): Promise<TelemetryClient> {
// 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
Expand All @@ -195,7 +200,7 @@ export async function getTelemetry(cwd?: string): Promise<TelemetryClient> {
cli: anonymousId,
cliVersion: CLI_VERSION,
scaffoldVersion,
gh_owner: ghOwner,
ghOwner,
},
});

Expand All @@ -219,7 +224,7 @@ export async function getTelemetry(cwd?: string): Promise<TelemetryClient> {
cli: anonymousId,
cliVersion: CLI_VERSION,
scaffoldVersion,
gh_owner: ghOwner,
ghOwner,
},
...(!anonymous && orgSubject !== undefined
? { groups: { organization: String(orgSubject) } }
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/test/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,20 +376,20 @@ 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<string, unknown>;
};
return call.properties?.gh_owner;
return call.properties?.ghOwner;
}

/**
* Which GitHub owner is using the CLI, including anonymously. The property is
* 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 {
Expand Down Expand Up @@ -442,7 +442,7 @@ describe("gh_owner", () => {
const identifyCall = mockIdentify.mock.calls.at(-1)?.[0] as {
properties: Record<string, unknown>;
};
expect(identifyCall.properties).toHaveProperty("gh_owner");
expect(identifyCall.properties).toHaveProperty("ghOwner");
expect(lastGhOwner(mockIdentify)).toBe("[unknown]");
expect(lastGhOwner(mockCapture)).toBe("[unknown]");
} finally {
Expand Down
Loading