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
33 changes: 33 additions & 0 deletions packages/cli/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,36 @@ When creating a bound `createSentryClient` instead, register
Passing an existing client in its constructor config does not transfer
interceptors. Keep the runtime SDK import outside `sentry-client.ts`, which is
also loaded synchronously by telemetry's database integration.

### Invocation telemetry

When telemetry is enabled, `is_ci` records `true` or `false` for each invocation,
independently of the `agent` and `is_tty` tags. CI detection reuses the environment
variable detector used for VCS metadata collection, including `CI` and known
provider markers. Telemetry records the detection result without sending raw
environment variable values. The signals can overlap: an agent running in CI
retains its agent identity and also has `is_ci:true`.

For command usage widgets, retain the existing base query:

```text
!span.description:cli-execution !span.description:[sentry.auth.login,sentry.auth.logout,sentry.auth.refresh] span.op:cli.command
```

Keep the `count(span.duration)` aggregate and append the relevant condition:

| Segment | Additional condition |
|---------|----------------------|
| Agent | `!agent:""` |
| CI | `agent:"" is_ci:true` |
| Human | `agent:"" is_ci:false` |
| Unknown | `agent:"" is_ci:""` |

These segments give detected agents priority, including agents running in CI.
To inspect that subset, append `!agent:"" is_ci:true` to the base query.
The Human label remains an approximation: no detected CI or agent does not prove
human execution; local scripts and undetected agents can also fall into this
segment. Older telemetry without `is_ci` stays Unknown when no agent was detected.
Do not treat an absent `is_ci` tag as `false`.

Update usage dashboards after releasing the CLI version that emits `is_ci`.
2 changes: 1 addition & 1 deletion packages/cli/src/commands/build/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import {
} from "../../lib/build/index.js";
import {
collectVcsMetadata,
isCi,
type VcsFlags,
vcsInfoToBody,
} from "../../lib/build/vcs.js";
import { isCi } from "../../lib/ci.js";
import { buildCommand } from "../../lib/command.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/snapshots/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import {
} from "../../lib/api/preprod-artifacts.js";
import {
collectVcsMetadata,
isCi,
type VcsFlags,
type VcsInfo,
vcsInfoToBody,
} from "../../lib/build/vcs.js";
import { isCi } from "../../lib/ci.js";
import { buildCommand } from "../../lib/command.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import {
Expand Down
33 changes: 0 additions & 33 deletions packages/cli/src/lib/build/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,39 +59,6 @@ export type VcsFlags = {
"no-git-metadata"?: boolean;
};

/** Environment variables commonly set by CI providers. */
const CI_ENV_VARS = [
"CI",
"JENKINS_URL",
"HUDSON_URL",
"TEAMCITY_VERSION",
"CIRCLE_BUILD_URL",
"bamboo_resultsUrl",
"GITHUB_ACTIONS",
"GITLAB_CI",
"TRAVIS_JOB_ID",
"BITRISE_BUILD_URL",
"GO_SERVER_URL",
"TF_BUILD",
"BUILDKITE",
];

/**
* Detect whether we are running in a CI environment.
*
* @param env - Environment variables (defaults to `process.env`).
*/
export function isCi(env: NodeJS.ProcessEnv = process.env): boolean {
return CI_ENV_VARS.some((name) => {
const value = env[name];
// Treat explicit opt-out values ("", "false", "0") as not-in-CI so a local
// `CI=false` does not silently enable git-metadata collection.
return (
value !== undefined && value !== "" && value !== "false" && value !== "0"
);
});
}

/** A 40-character lowercase hex SHA-1. */
const SHA1_RE = /^[0-9a-f]{40}$/;

Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/lib/ci.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** Shared CI detection for command telemetry and VCS metadata collection. */

/** Environment variables commonly set by CI providers. */
const CI_ENV_VARS = [
"CI",
"JENKINS_URL",
"HUDSON_URL",
"TEAMCITY_VERSION",
"CIRCLE_BUILD_URL",
"bamboo_resultsUrl",
"GITHUB_ACTIONS",
"GITLAB_CI",
"TRAVIS_JOB_ID",
"BITRISE_BUILD_URL",
"GO_SERVER_URL",
"TF_BUILD",
"BUILDKITE",
];

/**
* Detect CI from generic or provider-specific environment markers.
* Empty strings, "false", and "0" are ignored for each marker.
*
* @param env - Environment variables (defaults to `process.env`).
*/
export function isCi(env: NodeJS.ProcessEnv = process.env): boolean {
return CI_ENV_VARS.some((name) => {
const value = env[name];
return (
value !== undefined && value !== "" && value !== "false" && value !== "0"
);
});
}
6 changes: 5 additions & 1 deletion packages/cli/src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as Sentry from "@sentry/node";
const _require = createRequire(import.meta.url);

import { isMusl } from "./binary.js";
import { isCi } from "./ci.js";
import {
CLI_VERSION,
getCliEnvironment,
Expand Down Expand Up @@ -757,7 +758,10 @@ export function initSentry(
// Tag whether targeting self-hosted Sentry (not SaaS)
Sentry.setTag("is_self_hosted", !isSentrySaasUrl(getSentryBaseUrl()));

// Tag whether running in an interactive terminal or agent/CI environment
// CI and agent identity are independent: an agent can run inside CI.
Sentry.setTag("is_ci", isCi(getEnv()));

// Tag whether stdout is a terminal, independently of CI or agent detection.
Sentry.setTag("is_tty", !!process.stdout.isTTY);

// Tag the C library variant on Linux (musl vs glibc).
Expand Down
15 changes: 0 additions & 15 deletions packages/cli/test/lib/build/vcs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ vi.mock("../../../src/lib/git.js", () => gitMock);
import { ValidationError } from "../../../src/lib/errors.js";
import {
collectVcsMetadata,
isCi,
vcsInfoToBody,
} from "../../../src/lib/build/vcs.js";

Expand All @@ -46,20 +45,6 @@ afterEach(() => {
}
});

describe("isCi", () => {
test("true when a CI variable is set", () => {
expect(isCi({ GITHUB_ACTIONS: "true" })).toBe(true);
expect(isCi({ CI: "1" })).toBe(true);
});

test("false with no CI variables (and ignores opt-out values)", () => {
expect(isCi({})).toBe(false);
expect(isCi({ CI: "" })).toBe(false);
expect(isCi({ CI: "false" })).toBe(false);
expect(isCi({ CI: "0" })).toBe(false);
});
});

describe("vcsInfoToBody", () => {
test("flattens to snake_case and renames provider", () => {
expect(
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/test/lib/ci.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** Tests for the shared CI environment detector. */

import { describe, expect, test } from "vitest";
import { isCi } from "../../src/lib/ci.js";

describe("isCi", () => {
test("true when a CI variable is set", () => {
expect(isCi({ GITHUB_ACTIONS: "true" })).toBe(true);
expect(isCi({ TF_BUILD: "True" })).toBe(true);
expect(isCi({ CI: "1" })).toBe(true);
});

test("false with no CI variables (and ignores opt-out values)", () => {
expect(isCi({})).toBe(false);
expect(isCi({ CI: "" })).toBe(false);
expect(isCi({ CI: "false" })).toBe(false);
expect(isCi({ CI: "0" })).toBe(false);
});

test("detects provider markers even when the generic marker is disabled", () => {
expect(isCi({ CI: "false", GITHUB_ACTIONS: "true" })).toBe(true);
});
});
58 changes: 58 additions & 0 deletions packages/cli/test/lib/telemetry-ci.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** Verify CI and agent attribution on outgoing command telemetry. */

import type { Transport } from "@sentry/core";
import { captureMessage, startSpan, withIsolationScope } from "@sentry/node";
import { afterEach, describe, expect, test, vi } from "vitest";
import { withEnv } from "../../src/lib/env.js";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- intercept the outbound transport factory
import * as transportModule from "../../src/lib/telemetry/zstd-transport.js";
import { initSentry } from "../../src/lib/telemetry.js";

let client: ReturnType<typeof initSentry>;

afterEach(async () => {
await client?.close(0);
vi.restoreAllMocks();
});

describe("CI telemetry", () => {
test.each([
{ env: { CI: "true" }, expected: true },
{ env: { GITHUB_ACTIONS: "true" }, expected: true },
{ env: { CI: "false" }, expected: false },
{ env: {}, expected: false },
])(
"records is_ci=$expected for $env without losing agent identity",
async ({ env, expected }) => {
const send = vi.fn<Transport["send"]>().mockResolvedValue({});
vi.spyOn(transportModule, "makeCompressedTransport").mockReturnValue({
send,
flush: async () => true,
});

await withEnv({ ...env, AI_AGENT: "codex" }, () =>
withIsolationScope(async () => {
client = initSentry(true, { libraryMode: true });
if (!client) {
throw new Error("Expected the telemetry client");
}

captureMessage("CI attribution");
startSpan(
{ name: "sentry.info", op: "cli.command", forceTransaction: true },
() => {},
);
await client.flush(1000);
}),
);

const items = send.mock.calls.flatMap(([envelope]) => envelope[1]);
for (const type of ["event", "transaction"]) {
const event = items.find(([header]) => header.type === type)?.[1];
expect(event).toMatchObject({
tags: { is_ci: expected, agent: "codex" },
});
}
},
);
});
Loading