@theholocron/holocron-plugin-sentry 5.0.0-alpha.221 → 5.0.0-alpha.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +20 -3
- package/dist/index.mjs +226 -4
- package/package.json +5 -5
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { AuthError, Errors, ResolveTokenInput, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess } from "@theholocron/cli";
|
|
1
|
+
import { AuthError, EnsureRelayInput, EnsureRelayResult, Errors, ResolveTokenInput, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess } from "@theholocron/cli";
|
|
2
2
|
import { SentryClient, SentryClientOptions, createSentryClient } from "@theholocron/sentry-client";
|
|
3
3
|
//#region src/auth.d.ts
|
|
4
4
|
declare const resolveToken: (input?: ResolveTokenInput) => string;
|
|
5
|
+
/**
|
|
6
|
+
* The token for the Comlink relay: an integration, its client secret and an alert rule need more than the general token
|
|
7
|
+
* (`alerts:write`, `org:write`, `event:read`), so it has its own.
|
|
8
|
+
*/
|
|
9
|
+
declare const resolveComlinkToken: (input?: ResolveTokenInput) => string;
|
|
10
|
+
/**
|
|
11
|
+
* The token for the relay: the Comlink one when there is one, else the general Sentry token as the last resort. The
|
|
12
|
+
* fallback only helps if the general token happens to carry the scopes; otherwise Sentry's answer names what is missing.
|
|
13
|
+
*/
|
|
14
|
+
declare function resolveRelayToken(input?: ResolveTokenInput): string;
|
|
5
15
|
//#endregion
|
|
6
16
|
//#region src/capabilities/errors.d.ts
|
|
7
17
|
interface SentryErrorsOptions {
|
|
@@ -13,9 +23,14 @@ interface SentryErrorsOptions {
|
|
|
13
23
|
declare class SentryErrors implements Errors {
|
|
14
24
|
private readonly client;
|
|
15
25
|
private readonly opts;
|
|
26
|
+
/** The client for the relay, made with the Comlink token (see `resolveRelayToken`). */
|
|
27
|
+
private readonly relayClient;
|
|
16
28
|
readonly key: "errors";
|
|
17
29
|
readonly providerName = "sentry";
|
|
18
|
-
constructor(client: () => SentryClient, opts: SentryErrorsOptions
|
|
30
|
+
constructor(client: () => SentryClient, opts: SentryErrorsOptions,
|
|
31
|
+
/** The client for the relay, made with the Comlink token (see `resolveRelayToken`). */
|
|
32
|
+
relayClient?: () => SentryClient);
|
|
33
|
+
ensureRelay(input: EnsureRelayInput): Promise<EnsureRelayResult>;
|
|
19
34
|
describe(): Promise<{
|
|
20
35
|
provider: string;
|
|
21
36
|
envKeys: string[];
|
|
@@ -60,6 +75,8 @@ interface SentryPluginOptions extends ResolveTokenInput, SentryErrorsOptions {
|
|
|
60
75
|
interface PluginContext {
|
|
61
76
|
options: SentryPluginOptions;
|
|
62
77
|
client: () => SentryClient;
|
|
78
|
+
/** The client for the Comlink relay: its own token (`sentry.comlink`), the general one only as a last resort. */
|
|
79
|
+
relayClient: () => SentryClient;
|
|
63
80
|
}
|
|
64
81
|
declare function createContext(options: SentryPluginOptions): PluginContext;
|
|
65
82
|
declare function errors(ctx: PluginContext): Errors;
|
|
@@ -71,4 +88,4 @@ declare function createPlugin(options: SentryPluginOptions): {
|
|
|
71
88
|
};
|
|
72
89
|
declare const AUTH_HINT: string;
|
|
73
90
|
//#endregion
|
|
74
|
-
export { AUTH_HINT, AuthError, PluginContext, type ResolveTokenInput, type SentryClient, type SentryClientOptions, SentryErrors, type SentryErrorsOptions, SentryPluginOptions, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createContext, createPlugin, createSentryClient, errors, resolveToken, verifyToken };
|
|
91
|
+
export { AUTH_HINT, AuthError, PluginContext, type ResolveTokenInput, type SentryClient, type SentryClientOptions, SentryErrors, type SentryErrorsOptions, SentryPluginOptions, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createContext, createPlugin, createSentryClient, errors, resolveComlinkToken, resolveRelayToken, resolveToken, verifyToken };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthError, ProviderApiError, createResolveToken } from "@theholocron/cli";
|
|
1
|
+
import { AuthError, ProviderApiError, createFeatureResolver, createResolveToken } from "@theholocron/cli";
|
|
2
2
|
import { createSentryClient } from "@theholocron/sentry-client";
|
|
3
3
|
import { errorMessage } from "@theholocron/misc-utils";
|
|
4
4
|
//#region src/auth.ts
|
|
@@ -8,16 +8,232 @@ const resolveToken = createResolveToken({
|
|
|
8
8
|
keyringService: "sentry",
|
|
9
9
|
errorMessage: "no Sentry auth token found. Pass --token <TOKEN>, set HOLOCRON_SENTRY_TOKEN / SENTRY_AUTH_TOKEN, or run: holocron auth set sentry <TOKEN>"
|
|
10
10
|
});
|
|
11
|
+
/**
|
|
12
|
+
* The token for the Comlink relay: an integration, its client secret and an alert rule need more than the general token
|
|
13
|
+
* (`alerts:write`, `org:write`, `event:read`), so it has its own.
|
|
14
|
+
*/
|
|
15
|
+
const resolveComlinkToken = createFeatureResolver({
|
|
16
|
+
envName: "HOLOCRON_SENTRY_COMLINK_TOKEN",
|
|
17
|
+
keyringKey: "sentry.comlink",
|
|
18
|
+
vendor: "Sentry"
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* The token for the relay: the Comlink one when there is one, else the general Sentry token as the last resort. The
|
|
22
|
+
* fallback only helps if the general token happens to carry the scopes; otherwise Sentry's answer names what is missing.
|
|
23
|
+
*/
|
|
24
|
+
function resolveRelayToken(input = {}) {
|
|
25
|
+
try {
|
|
26
|
+
return resolveComlinkToken(input);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (!(err instanceof AuthError)) throw err;
|
|
29
|
+
return resolveToken(input);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/capabilities/relay.ts
|
|
34
|
+
/** What the integration needs to read: the alert's event. It is also why reading its secret needs the same scope. */
|
|
35
|
+
const INTEGRATION_SCOPES = ["event:read"];
|
|
36
|
+
const DEFAULT_FREQUENCY = 5;
|
|
37
|
+
/** The alert action that sends through an integration: it points at the integration's numeric app id. */
|
|
38
|
+
const sentryAppAction = (appId) => ({
|
|
39
|
+
type: "sentry_app",
|
|
40
|
+
config: {
|
|
41
|
+
targetType: "sentry_app",
|
|
42
|
+
targetIdentifier: String(appId)
|
|
43
|
+
},
|
|
44
|
+
data: {}
|
|
45
|
+
});
|
|
46
|
+
const targetsApp = (action, appId) => action.type === "sentry_app" && String(action.config["targetIdentifier"]) === String(appId);
|
|
47
|
+
/** A workflow as Sentry returned it, as the body to send back (the nested ids are not part of an update). */
|
|
48
|
+
function toInput(wf) {
|
|
49
|
+
return {
|
|
50
|
+
name: wf.name,
|
|
51
|
+
enabled: wf.enabled,
|
|
52
|
+
environment: wf.environment,
|
|
53
|
+
config: wf.config,
|
|
54
|
+
detectorIds: wf.detectorIds,
|
|
55
|
+
triggers: {
|
|
56
|
+
logicType: wf.triggers.logicType,
|
|
57
|
+
conditions: wf.triggers.conditions.map((c) => ({
|
|
58
|
+
type: c.type,
|
|
59
|
+
comparison: c.comparison,
|
|
60
|
+
conditionResult: c.conditionResult
|
|
61
|
+
}))
|
|
62
|
+
},
|
|
63
|
+
actionFilters: wf.actionFilters.map((f) => ({
|
|
64
|
+
logicType: f.logicType,
|
|
65
|
+
conditions: f.conditions.map((c) => ({
|
|
66
|
+
type: c.type,
|
|
67
|
+
comparison: c.comparison,
|
|
68
|
+
conditionResult: c.conditionResult
|
|
69
|
+
})),
|
|
70
|
+
actions: f.actions.map((a) => ({
|
|
71
|
+
type: a.type,
|
|
72
|
+
config: a.config,
|
|
73
|
+
data: a.data
|
|
74
|
+
}))
|
|
75
|
+
}))
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Sentry's own "high priority issues" rule, which is the default for a new alert. */
|
|
79
|
+
function newWorkflow(name, frequency, detectorIds, appId) {
|
|
80
|
+
return {
|
|
81
|
+
name,
|
|
82
|
+
enabled: true,
|
|
83
|
+
environment: null,
|
|
84
|
+
config: { frequency },
|
|
85
|
+
detectorIds,
|
|
86
|
+
triggers: {
|
|
87
|
+
logicType: "any-short",
|
|
88
|
+
conditions: [{
|
|
89
|
+
type: "new_high_priority_issue",
|
|
90
|
+
comparison: true,
|
|
91
|
+
conditionResult: true
|
|
92
|
+
}, {
|
|
93
|
+
type: "existing_high_priority_issue",
|
|
94
|
+
comparison: true,
|
|
95
|
+
conditionResult: true
|
|
96
|
+
}]
|
|
97
|
+
},
|
|
98
|
+
actionFilters: [{
|
|
99
|
+
logicType: "all",
|
|
100
|
+
conditions: [],
|
|
101
|
+
actions: [sentryAppAction(appId)]
|
|
102
|
+
}]
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** The integration: found by name, made if missing, and its webhook and alert action brought in line. */
|
|
106
|
+
async function ensureIntegration(client, org, input) {
|
|
107
|
+
const existing = (await client.sentryApps.list(org)).find((a) => a.name === input.name);
|
|
108
|
+
if (!existing) return {
|
|
109
|
+
app: await client.sentryApps.create(org, {
|
|
110
|
+
name: input.name,
|
|
111
|
+
scopes: INTEGRATION_SCOPES,
|
|
112
|
+
webhookUrl: input.webhookUrl,
|
|
113
|
+
isAlertable: true
|
|
114
|
+
}),
|
|
115
|
+
created: true,
|
|
116
|
+
updated: []
|
|
117
|
+
};
|
|
118
|
+
const updated = [];
|
|
119
|
+
if (existing.webhookUrl !== input.webhookUrl) updated.push("webhook URL");
|
|
120
|
+
if (!existing.isAlertable) updated.push("alert action");
|
|
121
|
+
const scopes = [.../* @__PURE__ */ new Set([...existing.scopes, ...INTEGRATION_SCOPES])];
|
|
122
|
+
if (scopes.length !== existing.scopes.length) updated.push("scopes");
|
|
123
|
+
if (updated.length === 0) return {
|
|
124
|
+
app: existing,
|
|
125
|
+
created: false,
|
|
126
|
+
updated
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
app: await client.sentryApps.update(existing.slug, {
|
|
130
|
+
name: existing.name,
|
|
131
|
+
scopes,
|
|
132
|
+
webhookUrl: input.webhookUrl,
|
|
133
|
+
isAlertable: true
|
|
134
|
+
}),
|
|
135
|
+
created: false,
|
|
136
|
+
updated
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** The issue stream detector of each project, so a wrong project fails before anything is changed. */
|
|
140
|
+
async function detectorsFor(client, org, slugs) {
|
|
141
|
+
const [projects, detectors] = await Promise.all([client.projects.list(org), client.workflows.detectors(org)]);
|
|
142
|
+
return slugs.map((slug) => {
|
|
143
|
+
const project = projects.find((p) => p.slug === slug);
|
|
144
|
+
if (!project) throw new Error(`Sentry project \`${slug}\` was not found in ${org}`);
|
|
145
|
+
const detector = detectors.find((d) => d.projectId === project.id && d.type === "issue_stream");
|
|
146
|
+
if (!detector) throw new Error(`Sentry project \`${slug}\` has no issue stream detector to attach an alert to`);
|
|
147
|
+
return detector.id;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** The alert rule: one workflow for every project, found by name, made if missing, reconciled if not. */
|
|
151
|
+
async function ensureRule(client, org, input, appId, detectorIds) {
|
|
152
|
+
const workflows = await client.workflows.list(org);
|
|
153
|
+
const frequency = input.rule.frequency ?? DEFAULT_FREQUENCY;
|
|
154
|
+
const existing = workflows.find((w) => w.name === input.rule.name);
|
|
155
|
+
if (!existing) {
|
|
156
|
+
await client.workflows.create(org, newWorkflow(input.rule.name, frequency, detectorIds, appId));
|
|
157
|
+
return {
|
|
158
|
+
created: true,
|
|
159
|
+
updated: []
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const next = toInput(existing);
|
|
163
|
+
const updated = [];
|
|
164
|
+
if (!existing.enabled) {
|
|
165
|
+
next.enabled = true;
|
|
166
|
+
updated.push("enabled");
|
|
167
|
+
}
|
|
168
|
+
if (input.rule.frequency !== void 0 && existing.config.frequency !== input.rule.frequency) {
|
|
169
|
+
next.config = {
|
|
170
|
+
...existing.config,
|
|
171
|
+
frequency: input.rule.frequency
|
|
172
|
+
};
|
|
173
|
+
updated.push("frequency");
|
|
174
|
+
}
|
|
175
|
+
const missing = detectorIds.filter((id) => !existing.detectorIds.includes(id));
|
|
176
|
+
if (missing.length > 0) {
|
|
177
|
+
next.detectorIds = [...existing.detectorIds, ...missing];
|
|
178
|
+
updated.push("projects");
|
|
179
|
+
}
|
|
180
|
+
if (!existing.actionFilters.some((f) => f.actions.some((a) => targetsApp(a, appId)))) {
|
|
181
|
+
const open = next.actionFilters.find((f) => f.conditions.length === 0);
|
|
182
|
+
if (open) open.actions.push(sentryAppAction(appId));
|
|
183
|
+
else next.actionFilters.push({
|
|
184
|
+
logicType: "all",
|
|
185
|
+
conditions: [],
|
|
186
|
+
actions: [sentryAppAction(appId)]
|
|
187
|
+
});
|
|
188
|
+
updated.push("action");
|
|
189
|
+
}
|
|
190
|
+
if (updated.length === 0) return {
|
|
191
|
+
created: false,
|
|
192
|
+
updated
|
|
193
|
+
};
|
|
194
|
+
await client.workflows.update(org, existing.id, next);
|
|
195
|
+
return {
|
|
196
|
+
created: false,
|
|
197
|
+
updated
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** The signing secret, if the token may see it: a new integration's answer omits it, so it is read again. */
|
|
201
|
+
async function secretOf(client, app) {
|
|
202
|
+
if (app.clientSecret) return app.clientSecret;
|
|
203
|
+
return (await client.sentryApps.get(app.slug)).clientSecret || void 0;
|
|
204
|
+
}
|
|
205
|
+
async function ensureSentryRelay(client, org, input) {
|
|
206
|
+
if (input.projects.length === 0) throw new Error("relay needs at least one Sentry project to alert on");
|
|
207
|
+
const detectorIds = await detectorsFor(client, org, input.projects);
|
|
208
|
+
const integration = await ensureIntegration(client, org, input);
|
|
209
|
+
const install = (await client.sentryApps.installations(org)).find((i) => i.app.slug === integration.app.slug);
|
|
210
|
+
if (!install) throw new Error(`the integration \`${integration.app.name}\` is not installed on ${org}`);
|
|
211
|
+
const rule = await ensureRule(client, org, input, install.app.sentryAppId, detectorIds);
|
|
212
|
+
const clientSecret = await secretOf(client, integration.app);
|
|
213
|
+
return {
|
|
214
|
+
integration: {
|
|
215
|
+
created: integration.created,
|
|
216
|
+
updated: integration.updated
|
|
217
|
+
},
|
|
218
|
+
rule,
|
|
219
|
+
...clientSecret ? { clientSecret } : {}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
11
222
|
//#endregion
|
|
12
223
|
//#region src/capabilities/errors.ts
|
|
13
224
|
var SentryErrors = class {
|
|
14
225
|
client;
|
|
15
226
|
opts;
|
|
227
|
+
relayClient;
|
|
16
228
|
key = "errors";
|
|
17
229
|
providerName = "sentry";
|
|
18
|
-
constructor(client, opts) {
|
|
230
|
+
constructor(client, opts, relayClient = client) {
|
|
19
231
|
this.client = client;
|
|
20
232
|
this.opts = opts;
|
|
233
|
+
this.relayClient = relayClient;
|
|
234
|
+
}
|
|
235
|
+
async ensureRelay(input) {
|
|
236
|
+
return ensureSentryRelay(this.relayClient(), this.requireOrg(), input);
|
|
21
237
|
}
|
|
22
238
|
async describe() {
|
|
23
239
|
return {
|
|
@@ -94,12 +310,18 @@ async function verifyToken(token, opts = {}) {
|
|
|
94
310
|
//#region src/index.ts
|
|
95
311
|
function createContext(options) {
|
|
96
312
|
let client;
|
|
313
|
+
let relayClient;
|
|
97
314
|
return {
|
|
98
315
|
options,
|
|
99
316
|
client: () => client ??= createSentryClient({
|
|
100
317
|
token: resolveToken(options),
|
|
101
318
|
baseUrl: options.baseUrl,
|
|
102
319
|
fetch: options.fetch
|
|
320
|
+
}),
|
|
321
|
+
relayClient: () => relayClient ??= createSentryClient({
|
|
322
|
+
token: resolveRelayToken(options),
|
|
323
|
+
baseUrl: options.baseUrl,
|
|
324
|
+
fetch: options.fetch
|
|
103
325
|
})
|
|
104
326
|
};
|
|
105
327
|
}
|
|
@@ -107,7 +329,7 @@ function errors(ctx) {
|
|
|
107
329
|
return new SentryErrors(ctx.client, {
|
|
108
330
|
org: ctx.options.org,
|
|
109
331
|
team: ctx.options.team
|
|
110
|
-
});
|
|
332
|
+
}, ctx.relayClient);
|
|
111
333
|
}
|
|
112
334
|
function createPlugin(options) {
|
|
113
335
|
const ctx = createContext(options);
|
|
@@ -118,4 +340,4 @@ function createPlugin(options) {
|
|
|
118
340
|
}
|
|
119
341
|
const AUTH_HINT = "generate an auth token at https://sentry.io/settings/account/api/auth-tokens/ with project:read, project:write, and org:read scopes, then run: holocron auth set sentry <TOKEN>";
|
|
120
342
|
//#endregion
|
|
121
|
-
export { AUTH_HINT, AuthError, SentryErrors, createContext, createPlugin, createSentryClient, errors, resolveToken, verifyToken };
|
|
343
|
+
export { AUTH_HINT, AuthError, SentryErrors, createContext, createPlugin, createSentryClient, errors, resolveComlinkToken, resolveRelayToken, resolveToken, verifyToken };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theholocron/holocron-plugin-sentry",
|
|
3
|
-
"version": "5.0.0-alpha.
|
|
3
|
+
"version": "5.0.0-alpha.223",
|
|
4
4
|
"description": "Holocron plugin for Sentry. Implements the errors capability against Sentry's management API — project provisioning and DSN retrieval.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"errors",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@theholocron/eslint-config": "^8.8.2",
|
|
34
34
|
"@theholocron/http-client": "^1.43.1",
|
|
35
35
|
"@theholocron/misc-utils": "^1.16.0",
|
|
36
|
-
"@theholocron/sentry-client": "^1.
|
|
36
|
+
"@theholocron/sentry-client": "^1.54.0",
|
|
37
37
|
"@theholocron/tsconfig": "^8.8.2",
|
|
38
38
|
"@theholocron/tsdown-config": "^8.8.2",
|
|
39
39
|
"@theholocron/vitest-config": "^8.8.2",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"tsx": "4.23.15",
|
|
49
49
|
"typescript": "^5.9.3",
|
|
50
50
|
"vitest": "^4.1.11",
|
|
51
|
-
"@theholocron/cli": "5.0.0-alpha.
|
|
51
|
+
"@theholocron/cli": "5.0.0-alpha.223"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@theholocron/misc-utils": "^1.16.0",
|
|
55
|
-
"@theholocron/sentry-client": "^1.
|
|
56
|
-
"@theholocron/cli": "5.0.0-alpha.
|
|
55
|
+
"@theholocron/sentry-client": "^1.54.0",
|
|
56
|
+
"@theholocron/cli": "5.0.0-alpha.223"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
59
59
|
"@theholocron/sentry-client": {}
|