Skip to content
Draft
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
43 changes: 42 additions & 1 deletion packages/docs/src/content/docs/extend/sentry-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,28 @@ OAuth client secret used when a user connects their Sentry account.

</details>

<details class="plugin-config">
<summary><code>SENTRY_APP_CLIENT_ID</code></summary>

Client ID of the Sentry public integration that each org installs.

- **Define:** Set `SENTRY_APP_CLIENT_ID` in the deployment environment
- **Required:** Yes for org installations; otherwise no
- **Environment override:** `SENTRY_APP_CLIENT_ID`

</details>

<details class="plugin-config">
<summary><code>SENTRY_APP_CLIENT_SECRET</code></summary>

Client secret of the Sentry public integration. Junior uses it to verify the integration's webhooks and to get installation tokens.

- **Define:** Set `SENTRY_APP_CLIENT_SECRET` in the deployment environment
- **Required:** Yes for org installations; otherwise no
- **Environment override:** `SENTRY_APP_CLIENT_SECRET`

</details>

<details class="plugin-config">
<summary><code>SENTRY_READ_TOKEN</code></summary>

Expand Down Expand Up @@ -137,6 +159,24 @@ Junior uses the token for `GET`, `HEAD`, and `OPTIONS` requests to the Sentry AP

Anyone who can talk to Junior can read every project the token can read. Sentry audit logs show the integration, not the Slack user.

## Set up org installations

An internal integration reads only one org. To read more than one org, use a **Sentry public integration** that each org installs. Keep it unpublished: then only the org that owns it, or a Sentry superuser, can install it.

1. Create a public integration in the org that owns Junior.
2. Grant read-only permissions for **Project**, **Issue & Event**, **Organization**, and **Release**.
3. Set the webhook URL to `https://<junior-host>/api/webhooks/sentry`. Do not set a redirect URL, and leave **Verify Install** off.
4. Set `SENTRY_APP_CLIENT_ID` and `SENTRY_APP_CLIENT_SECRET` to the integration's credentials.
5. Redeploy Junior, then install the integration in each org.

Sentry sends an `installation` webhook when an org installs the integration. Junior saves the org and gets the first token from that webhook. Sentry does not send the webhook again, so if Junior misses it, uninstall and install the integration again.

Junior reads the org from the Sentry API path, such as `/api/0/organizations/<org>/`, `/api/0/projects/<org>/<project>/`, or `/api/0/teams/<org>/<team>/`, and uses that org's installation token. Junior denies reads of an org that has no installation. A path with no org, such as `/api/0/issues/<id>/`, uses the only installation. When more than one org installed the integration, use the org-scoped path instead.

When `SENTRY_READ_TOKEN` is also set, Junior uses it for every read that no installation covers. Use this while you move from the internal integration to the public integration.

An event automation without an identifier runs for every installed org. Its `projects` filter matches the same project slug in each org.

## Set up issue webhooks

Create a **Sentry internal integration** in the organization that should send issue webhooks. A public Sentry app is not required.
Expand Down Expand Up @@ -262,7 +302,8 @@ Create the watch or event automation before the webhook arrives. Junior does not
- **Sentry reports a missing scope:** Reconnect Sentry to grant the current scopes.
- **Sentry returns `403`:** Connect an account with access to the requested organization and project. With org read access, also check the integration's read permissions.
- **A scheduled task cannot read Sentry:** Set `SENTRY_READ_TOKEN`. Confirm the request targets the organization that installed the integration.
- **Webhooks are ignored:** Check `SENTRY_WEBHOOK_SECRET`, then confirm a matching watch or event automation exists. For metric alerts, also confirm the integration is a trigger action on the alert.
- **Junior is not installed in the Sentry org:** Install the public integration in that org. If it is installed but Junior still denies the read, uninstall and install it again.
- **Webhooks are ignored:** Check `SENTRY_WEBHOOK_SECRET` or `SENTRY_APP_CLIENT_SECRET`, then confirm a matching watch or event automation exists. For metric alerts, also confirm the integration is a trigger action on the alert.
- **Authorization links use the wrong host:** Set `JUNIOR_BASE_URL` to Junior's public URL.

## Next step
Expand Down
7 changes: 7 additions & 0 deletions packages/junior-sentry/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";

export default defineConfig({
dialect: "postgresql",
out: "./migrations",
schema: "./src/db/schema.ts",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE "junior_sentry_installations" (
"installation_uuid" text PRIMARY KEY NOT NULL,
"org_slug" text,
"updated_at" timestamp with time zone NOT NULL,
CONSTRAINT "junior_sentry_installations_org_slug_unique" UNIQUE("org_slug")
);
56 changes: 56 additions & 0 deletions packages/junior-sentry/migrations/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"id": "defa7480-9855-45ca-8b89-983965cd7b50",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.junior_sentry_installations": {
"name": "junior_sentry_installations",
"schema": "",
"columns": {
"installation_uuid": {
"name": "installation_uuid",
"type": "text",
"primaryKey": true,
"notNull": true
},
"org_slug": {
"name": "org_slug",
"type": "text",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"junior_sentry_installations_org_slug_unique": {
"name": "junior_sentry_installations_org_slug_unique",
"nullsNotDistinct": false,
"columns": ["org_slug"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
13 changes: 13 additions & 0 deletions packages/junior-sentry/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1793654458568,
"tag": "0000_sentry_installations",
"breakpoints": true
}
]
}
4 changes: 4 additions & 0 deletions packages/junior-sentry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
},
"files": [
"dist",
"migrations",
"skills"
],
"scripts": {
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
"db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts",
"lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts vitest.config.ts",
"prepare": "pnpm run build",
"prepack": "pnpm run build",
Expand All @@ -35,12 +37,14 @@
},
"dependencies": {
"@sentry/junior-plugin-api": "workspace:*",
"drizzle-orm": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@oxlint/plugins": "1.79.0",
"@sentry/junior-testing": "workspace:*",
"@types/node": "^25.9.1",
"drizzle-kit": "catalog:",
"oxlint": "^1.79.0",
"tsup": "^8.5.1",
"typescript": "^7.0.2",
Expand Down
5 changes: 5 additions & 0 deletions packages/junior-sentry/src/db/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
import * as sentrySqlSchema from "./schema.js";

/** Database contract for Sentry-owned installation records. */
export type SentryDb = PgDatabase<PgQueryResultHKT, typeof sentrySqlSchema>;
27 changes: 27 additions & 0 deletions packages/junior-sentry/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Drizzle source of truth for Sentry plugin SQL migrations.
*
* Update this schema first, then regenerate packaged migrations with
* `pnpm --filter @sentry/junior-sentry db:generate`.
*/
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";

/**
* Installations of Junior's Sentry public integration. A Sentry org has at
* most one installation of an integration, so the org slug finds the
* installation for a request. `installation` webhooks write the rows. Sentry
* has no API that lists them again, so this table is the only record.
*/
export const juniorSentryInstallations = pgTable(
"junior_sentry_installations",
{
installationUuid: text("installation_uuid").primaryKey(),
/**
* Lowercase slug of the org that installed the integration. It is null
* after the integration is uninstalled. The row stays, so a late retry
* of the install webhook cannot add the installation again.
*/
orgSlug: text("org_slug").unique(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
},
);
22 changes: 15 additions & 7 deletions packages/junior-sentry/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Sentry plugin runtime boundary.
*
* This package owns per-user Sentry OAuth, org read access, CLI setup,
* internal-integration issue and metric alert webhook normalization, and
* Sentry resource identities. Junior core owns watches and event automations.
* This package owns per-user Sentry OAuth, org read access, installations
* of the public integration, CLI setup, issue and metric alert webhook
* normalization, and Sentry resource identities. Junior core owns watches
* and event automations.
*/
import {
defineJuniorPlugin,
Expand All @@ -25,6 +26,7 @@ import {
issueSentryCredential,
sentryGrantForEgress,
} from "./org-read.js";
import { sentryAppConfig } from "./installations/tokens.js";
import { resolveSentryOAuthAccount } from "./oauth-account.js";
import { createSentryWebhookRoute } from "./webhooks/handler.js";
import { sentryWebhookSecret } from "./webhooks/secret.js";
Expand Down Expand Up @@ -64,15 +66,17 @@ export function sentryPlugin(): PluginRegistration {
matchFields: SENTRY_METRIC_ALERT_MATCH_FIELDS,
},
],
// One install is one Sentry org. Issue and metric alert events carry
// alert facts, and Junior adds nothing to Sentry, so they can run for
// every project. Use the `projects` filter to narrow them.
// Issue and metric alert events carry alert facts, and Junior adds
// nothing to Sentry, so they can run for every project. Use the
// `projects` filter to narrow them. They also run for every installed
// org, so install the integration in a second org only after events
// get an `org` filter.
events: Object.fromEntries(
[...SENTRY_ISSUE_EVENTS, ...SENTRY_METRIC_ALERT_EVENTS].map(
(eventType) => [eventType, { reach: "all" }],
),
),
isEnabled: () => Boolean(sentryWebhookSecret()),
isEnabled: () => Boolean(sentryWebhookSecret() || sentryAppConfig()),
normalizeIdentifier: (identifier) => identifier.toLowerCase(),
},
manifest: {
Expand All @@ -90,6 +94,8 @@ export function sentryPlugin(): PluginRegistration {
"Investigate Sentry telemetry, manage projects, teams, and alerting, and receive issue and metric alert webhooks",
displayName: "Sentry",
envVars: {
SENTRY_APP_CLIENT_ID: {},
SENTRY_APP_CLIENT_SECRET: {},
SENTRY_CLIENT_ID: {},
SENTRY_CLIENT_SECRET: {},
SENTRY_READ_TOKEN: {},
Expand Down Expand Up @@ -119,6 +125,8 @@ export function sentryPlugin(): PluginRegistration {
routes(ctx) {
return [
createSentryWebhookRoute({
appConfig: sentryAppConfig,
ctx,
events: ctx.events,
webhookSecret: sentryWebhookSecret,
}),
Expand Down
102 changes: 102 additions & 0 deletions packages/junior-sentry/src/installations/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { PluginContext } from "@sentry/junior-plugin-api";
import { and, eq, isNotNull, ne } from "drizzle-orm";
import type { SentryDb } from "../db/database.js";
import { juniorSentryInstallations } from "../db/schema.js";

const table = juniorSentryInstallations;
const INSTALLATIONS_CACHE_KEY = "installations";
// Install webhooks refresh the cache. The TTL only repairs a missed refresh.
const INSTALLATIONS_CACHE_MS = 5 * 60 * 1000;

/** One active installation of the public integration and its org. */
export interface SentryInstallation {
installationUuid: string;
/** Org slug. It is stored in lowercase. */
orgSlug: string;
}

async function listSentryInstallations(
db: SentryDb,
): Promise<SentryInstallation[]> {
const rows = await db
.select({
installationUuid: table.installationUuid,
orgSlug: table.orgSlug,
})
.from(table)
.where(isNotNull(table.orgSlug));
return rows.flatMap((row) =>
row.orgSlug
? [{ installationUuid: row.installationUuid, orgSlug: row.orgSlug }]
: [],
);
}

/**
* Read every active installation through the plugin cache, because egress
* reads them for each Sentry API request. Pass `refresh` after a write.
*/
export async function activeSentryInstallations(
ctx: Pick<PluginContext, "cache" | "db">,
options?: { refresh?: boolean },
): Promise<SentryInstallation[]> {
return await ctx.cache.getOrLoad(
INSTALLATIONS_CACHE_KEY,
async () => ({
value: await listSentryInstallations(ctx.db as SentryDb),
ttlMs: INSTALLATIONS_CACHE_MS,
}),
options,
);
}

/**
* Save one installation from an `installation.created` webhook, and return
* true when it is new. A Sentry retry of the same webhook changes nothing,
* and a removed installation is never added again. An older row for the
* same org is stale, such as after a missed uninstall, so it is removed.
*/
export async function saveSentryInstallation(
db: SentryDb,
installation: SentryInstallation,
): Promise<boolean> {
const orgSlug = installation.orgSlug.toLowerCase();
return await db.transaction(async (tx) => {
await tx
.delete(table)
.where(
and(
eq(table.orgSlug, orgSlug),
ne(table.installationUuid, installation.installationUuid),
),
);
const inserted = await tx
.insert(table)
.values({
installationUuid: installation.installationUuid,
orgSlug,
updatedAt: new Date(),
})
.onConflictDoNothing({ target: table.installationUuid })
.returning({ installationUuid: table.installationUuid });
return inserted.length > 0;
});
}

/**
* Mark an installation as removed after an `installation.deleted` webhook.
* The row stays without an org, so a late install retry cannot add it again.
*/
export async function removeSentryInstallation(
db: SentryDb,
installationUuid: string,
): Promise<void> {
const updatedAt = new Date();
await db
.insert(table)
.values({ installationUuid, orgSlug: null, updatedAt })
.onConflictDoUpdate({
target: table.installationUuid,
set: { orgSlug: null, updatedAt },
});
}
Loading
Loading