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
9 changes: 5 additions & 4 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ When creating your Sentry OAuth application:

## Environment Variables

| Variable | Description | Default |
| ------------------ | ------------------------------------ | -------------------- |
| `SENTRY_CLIENT_ID` | Sentry OAuth app client ID | (required) |
| `SENTRY_URL` | Sentry instance URL (for self-hosted)| `https://sentry.io` |
| Variable | Description | Default |
| ------------------ | ----------------------------------------------------- | -------------------- |
| `SENTRY_CLIENT_ID` | Sentry OAuth app client ID | (required) |
| `SENTRY_HOST` | Sentry instance URL (for self-hosted, takes precedence) | `https://sentry.io` |
| `SENTRY_URL` | Alias for `SENTRY_HOST` | `https://sentry.io` |

## Building

Expand Down
10 changes: 8 additions & 2 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@ The Sentry CLI can be configured through environment variables and a local datab

## Environment Variables

### `SENTRY_URL`
### `SENTRY_HOST`

Base URL of your Sentry instance. **Only needed for [self-hosted Sentry](./self-hosted/).** SaaS users (sentry.io) should not set this.

```bash
export SENTRY_URL=https://sentry.example.com
export SENTRY_HOST=https://sentry.example.com
```

When set, all API requests (including OAuth login) are directed to this URL instead of `https://sentry.io`. The CLI also sets this automatically when you pass a self-hosted Sentry URL as a command argument.

`SENTRY_HOST` takes precedence over `SENTRY_URL`. Both work identically — use whichever you prefer.

### `SENTRY_URL`

Alias for `SENTRY_HOST`. If both are set, `SENTRY_HOST` takes precedence.

### `SENTRY_ORG`

Default organization slug. Skips organization auto-detection.
Expand Down
13 changes: 7 additions & 6 deletions docs/src/content/docs/self-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ title: Self-Hosted Sentry
description: Using the Sentry CLI with a self-hosted Sentry instance
---

The CLI works with self-hosted Sentry instances. Set the `SENTRY_URL` environment variable to point at your instance:
The CLI works with self-hosted Sentry instances. Set the `SENTRY_HOST` (or `SENTRY_URL`) environment variable to point at your instance:

```bash
export SENTRY_URL=https://sentry.example.com
export SENTRY_HOST=https://sentry.example.com
```

## Authenticating
Expand All @@ -27,14 +27,14 @@ The OAuth device flow requires **Sentry 26.1.0 or later** and a public OAuth app
Pass your instance URL and the client ID:

```bash
SENTRY_URL=https://sentry.example.com SENTRY_CLIENT_ID=your-client-id sentry auth login
SENTRY_HOST=https://sentry.example.com SENTRY_CLIENT_ID=your-client-id sentry auth login
```

:::tip
You can export both variables in your shell profile so every CLI invocation picks them up:

```bash
export SENTRY_URL=https://sentry.example.com
export SENTRY_HOST=https://sentry.example.com
export SENTRY_CLIENT_ID=your-client-id
```
:::
Expand All @@ -48,7 +48,7 @@ If your instance is on an older version or you prefer not to create an OAuth app
3. Pass it to the CLI:

```bash
SENTRY_URL=https://sentry.example.com sentry auth login --token YOUR_TOKEN
SENTRY_HOST=https://sentry.example.com sentry auth login --token YOUR_TOKEN
```

## After Login
Expand All @@ -66,7 +66,8 @@ If you pass a self-hosted Sentry URL as a command argument (e.g., an issue or ev

| Variable | Description |
|----------|-------------|
| `SENTRY_URL` | Base URL of your Sentry instance |
| `SENTRY_HOST` | Base URL of your Sentry instance (takes precedence over `SENTRY_URL`) |
| `SENTRY_URL` | Alias for `SENTRY_HOST` |
| `SENTRY_CLIENT_ID` | Client ID of your public OAuth application |
| `SENTRY_ORG` | Default organization slug |
| `SENTRY_PROJECT` | Default project slug (supports `org/project` format) |
Expand Down
8 changes: 8 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export const DEFAULT_SENTRY_HOST = "sentry.io";
/** Default Sentry SaaS URL (control silo for OAuth and region discovery) */
export const DEFAULT_SENTRY_URL = `https://${DEFAULT_SENTRY_HOST}`;

/**
* Resolve the Sentry instance URL from environment variables.
* Checks SENTRY_HOST first, then SENTRY_URL, then falls back to undefined.
*/
export function getConfiguredSentryUrl(): string | undefined {
return process.env.SENTRY_HOST || process.env.SENTRY_URL || undefined;
}

/** CLI version string, available for help output and other uses */
export const CLI_VERSION =
typeof SENTRY_CLI_VERSION !== "undefined" ? SENTRY_CLI_VERSION : "0.0.0-dev";
Expand Down
10 changes: 5 additions & 5 deletions src/lib/dsn/code-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import path from "node:path";
import * as Sentry from "@sentry/bun";
import ignore, { type Ignore } from "ignore";
import pLimit from "p-limit";
import { DEFAULT_SENTRY_HOST } from "../constants.js";
import { DEFAULT_SENTRY_HOST, getConfiguredSentryUrl } from "../constants.js";
import { ConfigError } from "../errors.js";
import { logger } from "../logger.js";
import { withTracingSpan } from "../telemetry.js";
Expand Down Expand Up @@ -316,18 +316,18 @@ function isCommentedLine(trimmedLine: string): boolean {
* @returns The expected host domain for DSN validation
*/
function getExpectedHost(): string {
const sentryUrl = process.env.SENTRY_URL;
const sentryUrl = getConfiguredSentryUrl();

if (sentryUrl) {
// Self-hosted: only accept DSNs matching the configured host
try {
const url = new URL(sentryUrl);
return url.host;
} catch {
// Invalid SENTRY_URL - throw immediately since nothing will work
// Invalid SENTRY_HOST/SENTRY_URL - throw immediately since nothing will work
throw new ConfigError(
`SENTRY_URL "${sentryUrl}" is not a valid URL`,
"Set SENTRY_URL to a valid URL (e.g., https://sentry.example.com) or unset it to use sentry.io"
`SENTRY_HOST/SENTRY_URL "${sentryUrl}" is not a valid URL`,
"Set SENTRY_HOST/SENTRY_URL to a valid URL (e.g., https://sentry.example.com) or unset it to use sentry.io"
);
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TokenErrorResponseSchema,
TokenResponseSchema,
} from "../types/index.js";
import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "./constants.js";
import { setAuthToken } from "./db/auth.js";
import { ApiError, AuthError, ConfigError, DeviceFlowError } from "./errors.js";
import { withHttpSpan } from "./telemetry.js";
Expand All @@ -23,7 +24,7 @@ import { withHttpSpan } from "./telemetry.js";
* by the device flow and token refresh.
*/
function getSentryUrl(): string {
return process.env.SENTRY_URL ?? "https://sentry.io";
return getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL;
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/lib/region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { retrieveAnOrganization } from "@sentry/api";
import { getConfiguredSentryUrl } from "./constants.js";
import { getOrgByNumericId, getOrgRegion, setOrgRegion } from "./db/regions.js";
import { stripDsnOrgPrefix } from "./dsn/index.js";
import { withAuthGuard } from "./errors.js";
Expand Down Expand Up @@ -66,8 +67,8 @@ export async function resolveOrgRegion(orgSlug: string): Promise<string> {
* Returns false for self-hosted instances that don't have regional URLs.
*/
export function isMultiRegionEnabled(): boolean {
// Self-hosted instances (custom SENTRY_URL) typically don't have multi-region
const baseUrl = process.env.SENTRY_URL;
// Self-hosted instances (custom SENTRY_HOST/SENTRY_URL) typically don't have multi-region
const baseUrl = getConfiguredSentryUrl();
if (baseUrl && !isSentrySaasUrl(baseUrl)) {
return false;
}
Expand Down
10 changes: 7 additions & 3 deletions src/lib/sentry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
* through the SDK function options (baseUrl, fetch, headers).
*/

import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js";
import {
DEFAULT_SENTRY_URL,
getConfiguredSentryUrl,
getUserAgent,
} from "./constants.js";
import { getAuthToken, isEnvTokenActive, refreshToken } from "./db/auth.js";
import { getCachedResponse, storeCachedResponse } from "./response-cache.js";
import { withHttpSpan } from "./telemetry.js";
Expand Down Expand Up @@ -391,7 +395,7 @@ function getAuthenticatedFetch(): typeof fetch {
* Supports self-hosted instances via SENTRY_URL env var.
*/
export function getApiBaseUrl(): string {
return process.env.SENTRY_URL || DEFAULT_SENTRY_URL;
return getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL;
}

/**
Expand All @@ -402,7 +406,7 @@ export function getApiBaseUrl(): string {
* (e.g., from URL argument parsing for self-hosted instances) is respected.
*/
export function getControlSiloUrl(): string {
return process.env.SENTRY_URL || DEFAULT_SENTRY_URL;
return getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/lib/sentry-url-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,13 @@ export function parseSentryUrl(input: string): ParsedSentryUrl | null {
export function applySentryUrlContext(baseUrl: string): void {
if (isSentrySaasUrl(baseUrl)) {
// Clear any self-hosted URL so API calls fall back to default SaaS routing.
// Without this, a stale SENTRY_URL would route SaaS requests to the wrong host.
// Without this, a stale SENTRY_HOST/SENTRY_URL would route SaaS requests to the wrong host.
// biome-ignore lint/performance/noDelete: process.env requires delete to truly unset; assignment coerces to string in Node.js
delete process.env.SENTRY_HOST;
// biome-ignore lint/performance/noDelete: process.env requires delete to truly unset; assignment coerces to string in Node.js
delete process.env.SENTRY_URL;
return;
}
process.env.SENTRY_HOST = baseUrl;
process.env.SENTRY_URL = baseUrl;
}
8 changes: 6 additions & 2 deletions src/lib/sentry-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
* Supports self-hosted instances via SENTRY_URL environment variable.
*/

import { DEFAULT_SENTRY_HOST, DEFAULT_SENTRY_URL } from "./constants.js";
import {
DEFAULT_SENTRY_HOST,
DEFAULT_SENTRY_URL,
getConfiguredSentryUrl,
} from "./constants.js";

/**
* Get the Sentry web base URL.
* Supports self-hosted instances via SENTRY_URL env var.
*/
export function getSentryBaseUrl(): string {
return process.env.SENTRY_URL ?? DEFAULT_SENTRY_URL;
return getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL;
}

/**
Expand Down
10 changes: 9 additions & 1 deletion test/commands/event/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,16 @@ describe("parsePositionalArgs", () => {
});
});

// URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect
// URL integration tests — applySentryUrlContext may set SENTRY_HOST/SENTRY_URL as a side effect
describe("Sentry URL inputs", () => {
let savedSentryUrl: string | undefined;
let savedSentryHost: string | undefined;

beforeEach(() => {
savedSentryUrl = process.env.SENTRY_URL;
savedSentryHost = process.env.SENTRY_HOST;
delete process.env.SENTRY_URL;
delete process.env.SENTRY_HOST;
});

afterEach(() => {
Expand All @@ -173,6 +176,11 @@ describe("parsePositionalArgs", () => {
} else {
delete process.env.SENTRY_URL;
}
if (savedSentryHost !== undefined) {
process.env.SENTRY_HOST = savedSentryHost;
} else {
delete process.env.SENTRY_HOST;
}
});

test("event URL extracts eventId and passes org as OrgAll target", () => {
Expand Down
20 changes: 18 additions & 2 deletions test/lib/arg-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,16 @@ describe("parseOrgProjectArg", () => {
});
});

// URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect
// URL integration tests — applySentryUrlContext may set SENTRY_HOST/SENTRY_URL as a side effect
describe("Sentry URL inputs", () => {
let savedSentryUrl: string | undefined;
let savedSentryHost: string | undefined;

beforeEach(() => {
savedSentryUrl = process.env.SENTRY_URL;
savedSentryHost = process.env.SENTRY_HOST;
delete process.env.SENTRY_URL;
delete process.env.SENTRY_HOST;
});

afterEach(() => {
Expand All @@ -124,6 +127,11 @@ describe("parseOrgProjectArg", () => {
} else {
delete process.env.SENTRY_URL;
}
if (savedSentryHost !== undefined) {
process.env.SENTRY_HOST = savedSentryHost;
} else {
delete process.env.SENTRY_HOST;
}
});

test("issue URL returns org-all", () => {
Expand Down Expand Up @@ -228,13 +236,16 @@ describe("parseIssueArg", () => {
});
});

// URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect
// URL integration tests — applySentryUrlContext may set SENTRY_HOST/SENTRY_URL as a side effect
describe("Sentry URL inputs", () => {
let savedSentryUrl: string | undefined;
let savedSentryHost: string | undefined;

beforeEach(() => {
savedSentryUrl = process.env.SENTRY_URL;
savedSentryHost = process.env.SENTRY_HOST;
delete process.env.SENTRY_URL;
delete process.env.SENTRY_HOST;
});

afterEach(() => {
Expand All @@ -243,6 +254,11 @@ describe("parseIssueArg", () => {
} else {
delete process.env.SENTRY_URL;
}
if (savedSentryHost !== undefined) {
process.env.SENTRY_HOST = savedSentryHost;
} else {
delete process.env.SENTRY_HOST;
}
});

test("issue URL with numeric ID returns explicit-org-numeric", () => {
Expand Down
24 changes: 23 additions & 1 deletion test/lib/dsn/code-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe("Code Scanner", () => {

afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
delete process.env.SENTRY_HOST;
delete process.env.SENTRY_URL;
});

Expand Down Expand Up @@ -211,9 +212,30 @@ describe("Code Scanner", () => {

// Invalid SENTRY_URL should throw immediately since nothing will work
expect(() => extractDsnsFromContent(content)).toThrow(
/SENTRY_URL.*not a valid URL/
/SENTRY_HOST\/SENTRY_URL.*not a valid URL/
);
});

test("accepts self-hosted DSNs when SENTRY_HOST is set", () => {
process.env.SENTRY_HOST = "https://sentry.mycompany.com:9000";
const content = `
const DSN = "https://abc@sentry.mycompany.com:9000/123";
`;
const dsns = extractDsnsFromContent(content);
expect(dsns).toEqual(["https://abc@sentry.mycompany.com:9000/123"]);
});

test("SENTRY_HOST takes precedence over SENTRY_URL for DSN validation", () => {
process.env.SENTRY_HOST = "https://sentry.mycompany.com:9000";
process.env.SENTRY_URL = "https://sentry.other.com";
const content = `
const DSN1 = "https://abc@sentry.mycompany.com:9000/123";
const DSN2 = "https://def@sentry.other.com/456";
`;
const dsns = extractDsnsFromContent(content);
// Only the SENTRY_HOST DSN should be accepted
expect(dsns).toEqual(["https://abc@sentry.mycompany.com:9000/123"]);
});
});

describe("extractFirstDsnFromContent", () => {
Expand Down
Loading
Loading