@rawdash/connector-sentry 0.13.0
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/README.md +166 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +479 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# @rawdash/connector-sentry
|
|
2
|
+
|
|
3
|
+
Rawdash connector for [Sentry](https://sentry.io) — syncs issues, per-issue events, releases, and hourly error counts into the six-shape storage model.
|
|
4
|
+
|
|
5
|
+
## Auth setup
|
|
6
|
+
|
|
7
|
+
The connector authenticates with a Sentry auth token. Two flavours work; pick whichever fits how you administer Sentry:
|
|
8
|
+
|
|
9
|
+
- **Internal Integration token** (recommended for org-wide installs): Sentry → **Settings → Custom Integrations → New Internal Integration**. Give it `event:read`, `project:read`, and `org:read` scopes. The integration page surfaces a token starting with `sntrys_`.
|
|
10
|
+
- **User Auth Token**: Sentry → **Settings → Account → API → Auth Tokens**. Tied to a specific user — fine for personal use, but rotate it when the user leaves.
|
|
11
|
+
|
|
12
|
+
You also need your organization **slug** (the segment in your Sentry URL, e.g. `acme` in `https://sentry.io/organizations/acme/`).
|
|
13
|
+
|
|
14
|
+
## Configuration
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { SentryConnector } from '@rawdash/connector-sentry';
|
|
18
|
+
import { secret } from '@rawdash/core';
|
|
19
|
+
|
|
20
|
+
const sentry = new SentryConnector(
|
|
21
|
+
{
|
|
22
|
+
organization: 'acme',
|
|
23
|
+
// projects: ['web', 'api'], // optional — restrict to specific project slugs or IDs
|
|
24
|
+
// resources: ['issues', 'issue_events'], // optional — defaults to all four
|
|
25
|
+
// eventsPerIssueCap: 100, // optional — max events sampled per issue (default 100)
|
|
26
|
+
// statsLookbackHours: 24, // optional — hours of hourly stats refreshed per sync (default 24)
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
authToken: secret('SENTRY_AUTH_TOKEN'),
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or via `SentryConnector.create` (validates the input with the `configFields` Zod schema):
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const sentry = SentryConnector.create({
|
|
38
|
+
authToken: { $secret: 'SENTRY_AUTH_TOKEN' },
|
|
39
|
+
organization: 'acme',
|
|
40
|
+
// projects: ['web'],
|
|
41
|
+
// resources: ['issues', 'errors_per_hour'],
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Choosing resources
|
|
46
|
+
|
|
47
|
+
The connector exposes four resources, written across three internal sync phases:
|
|
48
|
+
|
|
49
|
+
| Resource | Phase | What gets written |
|
|
50
|
+
| ----------------- | ----------- | --------------------------------------------------------------------------------- |
|
|
51
|
+
| `issues` | issues | `sentry_issue` entities, one per Sentry group |
|
|
52
|
+
| `issue_events` | issues | `sentry_issue_event` events, sampled per issue (`eventsPerIssueCap`, default 100) |
|
|
53
|
+
| `releases` | releases | `sentry_release` entities |
|
|
54
|
+
| `errors_per_hour` | error_stats | `sentry_errors_per_hour` metric samples, hourly, per project |
|
|
55
|
+
|
|
56
|
+
`issue_events` shares the `issues` phase because each event is fetched against its parent issue. Enabling `issue_events` without `issues` still runs the issues query (so each issue's events can be located) but skips writing the issue entities themselves.
|
|
57
|
+
|
|
58
|
+
### Example dashboard
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { defineConfig, defineDashboard, defineMetric } from '@rawdash/core';
|
|
62
|
+
|
|
63
|
+
export default defineConfig({
|
|
64
|
+
connectors: [{ connector: sentry }],
|
|
65
|
+
dashboards: {
|
|
66
|
+
errors: defineDashboard({
|
|
67
|
+
widgets: {
|
|
68
|
+
unresolved_issues: {
|
|
69
|
+
kind: 'stat',
|
|
70
|
+
title: 'Unresolved issues',
|
|
71
|
+
metric: defineMetric({
|
|
72
|
+
connector: sentry,
|
|
73
|
+
shape: 'entity',
|
|
74
|
+
entityType: 'sentry_issue',
|
|
75
|
+
fn: 'count',
|
|
76
|
+
filter: [{ field: 'status', op: 'eq', value: 'unresolved' }],
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
errors_per_hour: {
|
|
80
|
+
kind: 'timeseries',
|
|
81
|
+
title: 'Errors per hour',
|
|
82
|
+
window: '24h',
|
|
83
|
+
metric: defineMetric({
|
|
84
|
+
connector: sentry,
|
|
85
|
+
shape: 'metric',
|
|
86
|
+
name: 'sentry_errors_per_hour',
|
|
87
|
+
fn: 'sum',
|
|
88
|
+
window: '24h',
|
|
89
|
+
groupBy: { field: 'ts', granularity: 'hour' },
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
issues_by_level: {
|
|
93
|
+
kind: 'distribution',
|
|
94
|
+
title: 'Issues by level',
|
|
95
|
+
metric: defineMetric({
|
|
96
|
+
connector: sentry,
|
|
97
|
+
shape: 'entity',
|
|
98
|
+
entityType: 'sentry_issue',
|
|
99
|
+
fn: 'count',
|
|
100
|
+
groupBy: { field: 'level' },
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Data model
|
|
110
|
+
|
|
111
|
+
| Storage shape | Entity/event/metric type | Key attributes |
|
|
112
|
+
| ------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
113
|
+
| entity | `sentry_issue` | shortId, title, level, status, firstSeen, lastSeen, count, userCount, projectSlug |
|
|
114
|
+
| event | `sentry_issue_event` | eventId, issueId, issueShortId, projectSlug, level, platform, environment, message |
|
|
115
|
+
| entity | `sentry_release` | version, projects, dateCreated, dateReleased, lastEvent |
|
|
116
|
+
| metric | `sentry_errors_per_hour` | value = error count for the hour; attributes = `{ project }`; one sample per (project, hour-aligned ts) |
|
|
117
|
+
|
|
118
|
+
Timestamps are stored as Unix epoch milliseconds. `sentry_issue_event` rows are sampled — the connector fetches at most `eventsPerIssueCap` recent events per issue per sync (Sentry caps a single `/events` page at 100), so the events shape is a representative sample, not a full audit trail.
|
|
119
|
+
|
|
120
|
+
## Sync behaviour
|
|
121
|
+
|
|
122
|
+
- **Backfill** (`mode: 'full'`): paginates `/api/0/organizations/{org}/issues/` and `/releases/` via Sentry's Link header (page size 100), respecting `results="true"` to stop cleanly. Issue and release entity scopes (plus the `sentry_issue_event` event scope) are cleared at the start of their phase so deletions in Sentry converge.
|
|
123
|
+
- **Incremental** (`mode: 'latest'`): applies `query=lastSeen:>{since}` to the issues endpoint so only issues with new occurrences are pulled. Releases and stats are still refreshed on every sync, since both are small and benefit from a fresh snapshot.
|
|
124
|
+
- **Rate limits**: Sentry sends `X-Sentry-Rate-Limit-Remaining` and `X-Sentry-Rate-Limit-Reset` on every response — the connector reports the parsed state back to the host via the shared `sentryRateLimit` policy so the engine can budget future requests. 429 responses are surfaced as `RateLimitError` by the shared HTTP client.
|
|
125
|
+
- **Resumable**: every paginated phase yields a `(phase, pageUrl)` cursor. Pagination URLs are sanitized on the way in — only `https://sentry.io/api/0/...` is accepted — to prevent a malicious or corrupted cursor from steering a follow-up request elsewhere.
|
|
126
|
+
|
|
127
|
+
## Errors
|
|
128
|
+
|
|
129
|
+
`@rawdash/connector-shared` maps Sentry's HTTP responses to typed errors automatically:
|
|
130
|
+
|
|
131
|
+
- `401` / `403` → `AuthError` — host stops syncing until the token is replaced.
|
|
132
|
+
- `429` → `RateLimitError` — host backs off and reschedules.
|
|
133
|
+
- `5xx` → `TransientError` — host retries on the next tick.
|
|
134
|
+
|
|
135
|
+
## Out of scope (post-MVP)
|
|
136
|
+
|
|
137
|
+
- Performance / Trace data — high cost, low signal for the launch dashboard set.
|
|
138
|
+
- Per-event payloads (stack traces, breadcrumbs) — the connector counts events but does not store their payloads.
|
|
139
|
+
- Self-hosted Sentry instances on custom hosts — pagination URLs are pinned to `sentry.io`.
|
|
140
|
+
|
|
141
|
+
## Registering in the MCP server
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { SentryConnector, configFields } from '@rawdash/connector-sentry';
|
|
145
|
+
|
|
146
|
+
createMcpServer({
|
|
147
|
+
// ...
|
|
148
|
+
connectorFactories: [
|
|
149
|
+
{
|
|
150
|
+
id: 'sentry',
|
|
151
|
+
configFields,
|
|
152
|
+
create: SentryConnector.create,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Property tests
|
|
159
|
+
|
|
160
|
+
Resources in this connector have fast-check property tests under `src/property.test.ts` that:
|
|
161
|
+
|
|
162
|
+
1. Generate synthetic API payloads from a Zod schema mirroring Sentry's response shape.
|
|
163
|
+
2. Pipe them through `connector.sync()` against an `InMemoryStorage` instance.
|
|
164
|
+
3. Assert universal invariants — non-empty entity ids, finite event timestamps, no `undefined` reaching storage, no thrown errors on any valid input — plus per-resource counts.
|
|
165
|
+
|
|
166
|
+
The helper lives in `@rawdash/connector-test-utils`. When adding a new resource, add a Zod schema for its payload and a test wired up via `runPropertySyncTest`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { BaseConnector, ConnectorContext, SyncOptions, StorageHandle, SyncResult } from '@rawdash/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
declare const configFields: z.ZodObject<{
|
|
5
|
+
authToken: z.ZodObject<{
|
|
6
|
+
$secret: z.ZodString;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
organization: z.ZodString;
|
|
9
|
+
projects: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
10
|
+
resources: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
11
|
+
issues: "issues";
|
|
12
|
+
issue_events: "issue_events";
|
|
13
|
+
releases: "releases";
|
|
14
|
+
errors_per_hour: "errors_per_hour";
|
|
15
|
+
}>>>;
|
|
16
|
+
eventsPerIssueCap: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
statsLookbackHours: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
type SentryResource = 'issues' | 'issue_events' | 'releases' | 'errors_per_hour';
|
|
20
|
+
interface SentrySettings {
|
|
21
|
+
organization: string;
|
|
22
|
+
projects?: readonly string[];
|
|
23
|
+
resources?: readonly SentryResource[];
|
|
24
|
+
eventsPerIssueCap?: number;
|
|
25
|
+
statsLookbackHours?: number;
|
|
26
|
+
}
|
|
27
|
+
declare const sentryCredentials: {
|
|
28
|
+
authToken: {
|
|
29
|
+
description: string;
|
|
30
|
+
auth: "required";
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
type SentryCredentials = typeof sentryCredentials;
|
|
34
|
+
declare class SentryConnector extends BaseConnector<SentrySettings, SentryCredentials> {
|
|
35
|
+
static readonly id = "sentry";
|
|
36
|
+
static create(input: unknown, ctx?: ConnectorContext): SentryConnector;
|
|
37
|
+
readonly id = "sentry";
|
|
38
|
+
readonly credentials: {
|
|
39
|
+
authToken: {
|
|
40
|
+
description: string;
|
|
41
|
+
auth: "required";
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
private buildHeaders;
|
|
45
|
+
private fetch;
|
|
46
|
+
private isResourceEnabled;
|
|
47
|
+
private activePhases;
|
|
48
|
+
private allowedPagePath;
|
|
49
|
+
private sanitizePageUrl;
|
|
50
|
+
private resolveCursor;
|
|
51
|
+
private buildInitialIssuesUrl;
|
|
52
|
+
private buildInitialReleasesUrl;
|
|
53
|
+
private buildStatsUrl;
|
|
54
|
+
private buildIssueEventsUrl;
|
|
55
|
+
private fetchIssuesPage;
|
|
56
|
+
private fetchReleasesPage;
|
|
57
|
+
private fetchErrorStats;
|
|
58
|
+
private writeIssuesPage;
|
|
59
|
+
private writeReleases;
|
|
60
|
+
private writeErrorStats;
|
|
61
|
+
sync(options: SyncOptions, storage: StorageHandle, signal?: AbortSignal): Promise<SyncResult>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { SentryConnector, type SentryResource, type SentrySettings, configFields, SentryConnector as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// ../../connector-shared/dist/index.js
|
|
2
|
+
var HTTP_CLIENT_VERSION = "0.0.0";
|
|
3
|
+
var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
|
|
4
|
+
var sentryRateLimit = {
|
|
5
|
+
parse(h) {
|
|
6
|
+
const concurrent = h.get("x-sentry-rate-limit-remaining");
|
|
7
|
+
const reset = h.get("x-sentry-rate-limit-reset");
|
|
8
|
+
if (concurrent === null || reset === null) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const remaining = Number(concurrent);
|
|
12
|
+
const resetSec = Number(reset);
|
|
13
|
+
if (!Number.isFinite(remaining) || !Number.isFinite(resetSec) || resetSec < 0) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return { remaining, resetAt: new Date(resetSec * 1e3) };
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// src/sentry.ts
|
|
21
|
+
import {
|
|
22
|
+
BaseConnector,
|
|
23
|
+
defineConfigFields,
|
|
24
|
+
paginateChunked
|
|
25
|
+
} from "@rawdash/core";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
var configFields = defineConfigFields(
|
|
28
|
+
z.object({
|
|
29
|
+
authToken: z.object({ $secret: z.string() }).meta({
|
|
30
|
+
label: "Auth Token",
|
|
31
|
+
description: "Sentry Internal Integration token or User Auth Token. Create one at Sentry \u2192 Settings \u2192 Auth Tokens (or for an org, Settings \u2192 Custom Integrations \u2192 New Internal Integration).",
|
|
32
|
+
placeholder: "sntrys_...",
|
|
33
|
+
secret: true
|
|
34
|
+
}),
|
|
35
|
+
organization: z.string().min(1).meta({
|
|
36
|
+
label: "Organization slug",
|
|
37
|
+
description: "Your Sentry organization's slug, as it appears in the URL.",
|
|
38
|
+
placeholder: "acme"
|
|
39
|
+
}),
|
|
40
|
+
projects: z.array(z.string().min(1)).nonempty().optional().meta({
|
|
41
|
+
label: "Projects (optional)",
|
|
42
|
+
description: "Restrict the sync to specific Sentry project slugs (or numeric IDs). Omit to sync every project the token can see."
|
|
43
|
+
}),
|
|
44
|
+
resources: z.array(z.enum(["issues", "issue_events", "releases", "errors_per_hour"])).nonempty().optional().meta({
|
|
45
|
+
label: "Resources",
|
|
46
|
+
description: "Which Sentry resources to sync. Omit to sync all of them. 'issue_events' depends on 'issues' being fetched \u2014 enabling it without 'issues' still runs the issues query, but skips writing issue entities."
|
|
47
|
+
}),
|
|
48
|
+
eventsPerIssueCap: z.number().int().positive().max(100).optional().meta({
|
|
49
|
+
label: "Events per issue cap",
|
|
50
|
+
description: "Maximum number of recent events (occurrences) to sample per issue on each sync. Defaults to 100 (the max page size Sentry allows for the issue events endpoint).",
|
|
51
|
+
placeholder: "100"
|
|
52
|
+
}),
|
|
53
|
+
statsLookbackHours: z.number().int().positive().max(168).optional().meta({
|
|
54
|
+
label: "Stats lookback (hours)",
|
|
55
|
+
description: "How many hours of hourly error-rate data to refresh on each sync. Defaults to 24.",
|
|
56
|
+
placeholder: "24"
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
var sentryCredentials = {
|
|
61
|
+
authToken: {
|
|
62
|
+
description: "Sentry auth token",
|
|
63
|
+
auth: "required"
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var PHASE_ORDER = ["issues", "releases", "error_stats"];
|
|
67
|
+
function isSentrySyncCursor(value) {
|
|
68
|
+
if (typeof value !== "object" || value === null) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const v = value;
|
|
72
|
+
if (typeof v.phase !== "string") {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (!PHASE_ORDER.includes(v.phase)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (v.page !== null && typeof v.page !== "string") {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
function safeTimestamp(iso) {
|
|
84
|
+
if (iso === null || iso === void 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const ms = new Date(iso).getTime();
|
|
88
|
+
return Number.isFinite(ms) ? ms : null;
|
|
89
|
+
}
|
|
90
|
+
function parseSentryLink(header, rel) {
|
|
91
|
+
if (!header) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
for (const part of header.split(",")) {
|
|
95
|
+
const m = part.match(/<([^>]+)>\s*;\s*(.+)$/);
|
|
96
|
+
if (!m) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const url = m[1];
|
|
100
|
+
const attrs = m[2];
|
|
101
|
+
const relMatch = attrs.match(/rel="([^"]+)"/);
|
|
102
|
+
if (!relMatch || relMatch[1] !== rel) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const resultsMatch = attrs.match(/results="([^"]+)"/);
|
|
106
|
+
const hasResults = resultsMatch ? resultsMatch[1] === "true" : true;
|
|
107
|
+
return { url, hasResults };
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
var SENTRY_API_HOST = "sentry.io";
|
|
112
|
+
var SENTRY_API_BASE = `https://${SENTRY_API_HOST}/api/0`;
|
|
113
|
+
var DEFAULT_EVENTS_PER_ISSUE = 100;
|
|
114
|
+
var DEFAULT_STATS_LOOKBACK_HOURS = 24;
|
|
115
|
+
var ISSUES_PAGE_SIZE = 100;
|
|
116
|
+
var RELEASES_PAGE_SIZE = 100;
|
|
117
|
+
var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
118
|
+
static id = "sentry";
|
|
119
|
+
static create(input, ctx) {
|
|
120
|
+
const parsed = configFields.parse(input);
|
|
121
|
+
return new _SentryConnector(
|
|
122
|
+
{
|
|
123
|
+
organization: parsed.organization,
|
|
124
|
+
projects: parsed.projects,
|
|
125
|
+
resources: parsed.resources,
|
|
126
|
+
eventsPerIssueCap: parsed.eventsPerIssueCap,
|
|
127
|
+
statsLookbackHours: parsed.statsLookbackHours
|
|
128
|
+
},
|
|
129
|
+
{ authToken: parsed.authToken },
|
|
130
|
+
ctx
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
id = "sentry";
|
|
134
|
+
credentials = sentryCredentials;
|
|
135
|
+
buildHeaders() {
|
|
136
|
+
return {
|
|
137
|
+
Authorization: `Bearer ${this.creds.authToken}`,
|
|
138
|
+
"User-Agent": "rawdash/connector-sentry (+https://rawdash.dev)"
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
fetch(url, resource, signal) {
|
|
142
|
+
return this.get(url, {
|
|
143
|
+
resource,
|
|
144
|
+
headers: this.buildHeaders(),
|
|
145
|
+
signal,
|
|
146
|
+
rateLimit: sentryRateLimit
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// -------------------------------------------------------------------------
|
|
150
|
+
// Resource enablement
|
|
151
|
+
// -------------------------------------------------------------------------
|
|
152
|
+
isResourceEnabled(resource) {
|
|
153
|
+
const enabled = this.settings.resources;
|
|
154
|
+
if (!enabled || enabled.length === 0) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return enabled.includes(resource);
|
|
158
|
+
}
|
|
159
|
+
activePhases() {
|
|
160
|
+
const phases = [];
|
|
161
|
+
if (this.isResourceEnabled("issues") || this.isResourceEnabled("issue_events")) {
|
|
162
|
+
phases.push("issues");
|
|
163
|
+
}
|
|
164
|
+
if (this.isResourceEnabled("releases")) {
|
|
165
|
+
phases.push("releases");
|
|
166
|
+
}
|
|
167
|
+
if (this.isResourceEnabled("errors_per_hour")) {
|
|
168
|
+
phases.push("error_stats");
|
|
169
|
+
}
|
|
170
|
+
return phases;
|
|
171
|
+
}
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
// URL building + sanitization
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
allowedPagePath(phase) {
|
|
176
|
+
const org = this.settings.organization;
|
|
177
|
+
switch (phase) {
|
|
178
|
+
case "issues":
|
|
179
|
+
return `/api/0/organizations/${org}/issues/`;
|
|
180
|
+
case "releases":
|
|
181
|
+
return `/api/0/organizations/${org}/releases/`;
|
|
182
|
+
case "error_stats":
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
sanitizePageUrl(phase, pageUrl) {
|
|
187
|
+
if (pageUrl === null) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const allowedPath = this.allowedPagePath(phase);
|
|
191
|
+
if (allowedPath === null) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const u = new URL(pageUrl);
|
|
196
|
+
if (u.protocol !== "https:" || u.host !== SENTRY_API_HOST || u.pathname !== allowedPath) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return u.toString();
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
resolveCursor(cursor) {
|
|
205
|
+
if (!isSentrySyncCursor(cursor)) {
|
|
206
|
+
return void 0;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
phase: cursor.phase,
|
|
210
|
+
page: this.sanitizePageUrl(cursor.phase, cursor.page)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
buildInitialIssuesUrl(options) {
|
|
214
|
+
const u = new URL(
|
|
215
|
+
`${SENTRY_API_BASE}/organizations/${this.settings.organization}/issues/`
|
|
216
|
+
);
|
|
217
|
+
u.searchParams.set("limit", String(ISSUES_PAGE_SIZE));
|
|
218
|
+
u.searchParams.set("sort", "date");
|
|
219
|
+
for (const project of this.settings.projects ?? []) {
|
|
220
|
+
u.searchParams.append("project", project);
|
|
221
|
+
}
|
|
222
|
+
if (options.mode === "latest" && options.since) {
|
|
223
|
+
u.searchParams.set("query", `lastSeen:>${options.since}`);
|
|
224
|
+
}
|
|
225
|
+
return u.toString();
|
|
226
|
+
}
|
|
227
|
+
buildInitialReleasesUrl() {
|
|
228
|
+
const u = new URL(
|
|
229
|
+
`${SENTRY_API_BASE}/organizations/${this.settings.organization}/releases/`
|
|
230
|
+
);
|
|
231
|
+
u.searchParams.set("per_page", String(RELEASES_PAGE_SIZE));
|
|
232
|
+
for (const project of this.settings.projects ?? []) {
|
|
233
|
+
u.searchParams.append("project", project);
|
|
234
|
+
}
|
|
235
|
+
return u.toString();
|
|
236
|
+
}
|
|
237
|
+
buildStatsUrl() {
|
|
238
|
+
const lookback = this.settings.statsLookbackHours ?? DEFAULT_STATS_LOOKBACK_HOURS;
|
|
239
|
+
const u = new URL(
|
|
240
|
+
`${SENTRY_API_BASE}/organizations/${this.settings.organization}/stats_v2/`
|
|
241
|
+
);
|
|
242
|
+
u.searchParams.set("field", "sum(quantity)");
|
|
243
|
+
u.searchParams.set("category", "error");
|
|
244
|
+
u.searchParams.set("interval", "1h");
|
|
245
|
+
u.searchParams.set("statsPeriod", `${lookback}h`);
|
|
246
|
+
u.searchParams.append("groupBy", "project");
|
|
247
|
+
for (const project of this.settings.projects ?? []) {
|
|
248
|
+
u.searchParams.append("project", project);
|
|
249
|
+
}
|
|
250
|
+
return u.toString();
|
|
251
|
+
}
|
|
252
|
+
buildIssueEventsUrl(issueId) {
|
|
253
|
+
const cap = this.settings.eventsPerIssueCap ?? DEFAULT_EVENTS_PER_ISSUE;
|
|
254
|
+
const u = new URL(`${SENTRY_API_BASE}/issues/${issueId}/events/`);
|
|
255
|
+
u.searchParams.set(
|
|
256
|
+
"limit",
|
|
257
|
+
String(Math.min(cap, DEFAULT_EVENTS_PER_ISSUE))
|
|
258
|
+
);
|
|
259
|
+
return u.toString();
|
|
260
|
+
}
|
|
261
|
+
// -------------------------------------------------------------------------
|
|
262
|
+
// Fetchers
|
|
263
|
+
// -------------------------------------------------------------------------
|
|
264
|
+
async fetchIssuesPage(page, options, signal) {
|
|
265
|
+
const url = page ?? this.buildInitialIssuesUrl(options);
|
|
266
|
+
const res = await this.fetch(url, "issues", signal);
|
|
267
|
+
const nextLink = parseSentryLink(res.headers.get("link"), "next");
|
|
268
|
+
const next = nextLink && nextLink.hasResults ? this.sanitizePageUrl("issues", nextLink.url) : null;
|
|
269
|
+
const eventsByIssue = /* @__PURE__ */ new Map();
|
|
270
|
+
if (this.isResourceEnabled("issue_events")) {
|
|
271
|
+
for (const issue of res.body) {
|
|
272
|
+
signal?.throwIfAborted();
|
|
273
|
+
const eventsRes = await this.fetch(
|
|
274
|
+
this.buildIssueEventsUrl(issue.id),
|
|
275
|
+
"issue_events",
|
|
276
|
+
signal
|
|
277
|
+
);
|
|
278
|
+
eventsByIssue.set(issue.id, eventsRes.body);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { items: [{ issues: res.body, eventsByIssue }], next };
|
|
282
|
+
}
|
|
283
|
+
async fetchReleasesPage(page, signal) {
|
|
284
|
+
const url = page ?? this.buildInitialReleasesUrl();
|
|
285
|
+
const res = await this.fetch(url, "releases", signal);
|
|
286
|
+
const nextLink = parseSentryLink(res.headers.get("link"), "next");
|
|
287
|
+
const next = nextLink && nextLink.hasResults ? this.sanitizePageUrl("releases", nextLink.url) : null;
|
|
288
|
+
return { items: res.body, next };
|
|
289
|
+
}
|
|
290
|
+
async fetchErrorStats(signal) {
|
|
291
|
+
const res = await this.fetch(
|
|
292
|
+
this.buildStatsUrl(),
|
|
293
|
+
"error_stats",
|
|
294
|
+
signal
|
|
295
|
+
);
|
|
296
|
+
return { items: [res.body], next: null };
|
|
297
|
+
}
|
|
298
|
+
// -------------------------------------------------------------------------
|
|
299
|
+
// Writers
|
|
300
|
+
// -------------------------------------------------------------------------
|
|
301
|
+
async writeIssuesPage(storage, item) {
|
|
302
|
+
const writeEntities = this.isResourceEnabled("issues");
|
|
303
|
+
const writeEvents = this.isResourceEnabled("issue_events");
|
|
304
|
+
for (const issue of item.issues) {
|
|
305
|
+
if (writeEntities) {
|
|
306
|
+
const count = typeof issue.count === "string" ? Number(issue.count) : issue.count;
|
|
307
|
+
const firstSeenMs = safeTimestamp(issue.firstSeen);
|
|
308
|
+
const lastSeenMs = safeTimestamp(issue.lastSeen);
|
|
309
|
+
if (firstSeenMs === null || lastSeenMs === null) {
|
|
310
|
+
console.warn(
|
|
311
|
+
`[connector-sentry] skipping issue ${issue.id} with unparseable firstSeen/lastSeen`
|
|
312
|
+
);
|
|
313
|
+
} else {
|
|
314
|
+
await storage.entity({
|
|
315
|
+
type: "sentry_issue",
|
|
316
|
+
id: issue.id,
|
|
317
|
+
attributes: {
|
|
318
|
+
shortId: issue.shortId,
|
|
319
|
+
title: issue.title,
|
|
320
|
+
level: issue.level,
|
|
321
|
+
status: issue.status,
|
|
322
|
+
firstSeen: firstSeenMs,
|
|
323
|
+
lastSeen: lastSeenMs,
|
|
324
|
+
count: Number.isFinite(count) ? count : 0,
|
|
325
|
+
userCount: issue.userCount,
|
|
326
|
+
projectSlug: issue.project.slug
|
|
327
|
+
},
|
|
328
|
+
updated_at: lastSeenMs
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (writeEvents) {
|
|
333
|
+
const events = item.eventsByIssue.get(issue.id) ?? [];
|
|
334
|
+
for (const ev of events) {
|
|
335
|
+
const eventId = ev.eventID ?? ev.id ?? null;
|
|
336
|
+
if (eventId === null) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const startTs = safeTimestamp(ev.dateCreated);
|
|
340
|
+
if (startTs === null) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
await storage.event({
|
|
344
|
+
name: "sentry_issue_event",
|
|
345
|
+
start_ts: startTs,
|
|
346
|
+
end_ts: null,
|
|
347
|
+
attributes: {
|
|
348
|
+
eventId,
|
|
349
|
+
issueId: issue.id,
|
|
350
|
+
issueShortId: issue.shortId,
|
|
351
|
+
projectSlug: issue.project.slug,
|
|
352
|
+
level: issue.level,
|
|
353
|
+
platform: ev.platform ?? null,
|
|
354
|
+
environment: ev.environment ?? null,
|
|
355
|
+
message: ev.message ?? null
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async writeReleases(storage, releases) {
|
|
363
|
+
for (const r of releases) {
|
|
364
|
+
const createdMs = safeTimestamp(r.dateCreated);
|
|
365
|
+
const releasedMs = safeTimestamp(r.dateReleased);
|
|
366
|
+
const lastEventMs = safeTimestamp(r.lastEvent);
|
|
367
|
+
if (createdMs === null) {
|
|
368
|
+
console.warn(
|
|
369
|
+
`[connector-sentry] skipping release ${r.version} with unparseable dateCreated`
|
|
370
|
+
);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
await storage.entity({
|
|
374
|
+
type: "sentry_release",
|
|
375
|
+
id: r.version,
|
|
376
|
+
attributes: {
|
|
377
|
+
version: r.version,
|
|
378
|
+
projects: r.projects.map((p) => p.slug),
|
|
379
|
+
dateCreated: createdMs,
|
|
380
|
+
dateReleased: releasedMs,
|
|
381
|
+
lastEvent: lastEventMs
|
|
382
|
+
},
|
|
383
|
+
updated_at: Math.max(createdMs, releasedMs ?? 0, lastEventMs ?? 0)
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async writeErrorStats(storage, stats) {
|
|
388
|
+
const samples = [];
|
|
389
|
+
for (const group of stats.groups) {
|
|
390
|
+
const project = group.by["project"];
|
|
391
|
+
const projectKey = project !== void 0 ? String(project) : "unknown";
|
|
392
|
+
const series = group.series["sum(quantity)"] ?? [];
|
|
393
|
+
for (let i = 0; i < stats.intervals.length; i++) {
|
|
394
|
+
const intervalIso = stats.intervals[i];
|
|
395
|
+
const rawValue = series[i];
|
|
396
|
+
if (intervalIso === void 0 || rawValue === void 0) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const ts = new Date(intervalIso).getTime();
|
|
400
|
+
const value = Number(rawValue);
|
|
401
|
+
if (!Number.isFinite(ts) || !Number.isFinite(value)) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
samples.push({
|
|
405
|
+
name: "sentry_errors_per_hour",
|
|
406
|
+
ts,
|
|
407
|
+
value,
|
|
408
|
+
attributes: { project: projectKey }
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
await storage.metrics(samples, { names: ["sentry_errors_per_hour"] });
|
|
413
|
+
}
|
|
414
|
+
// -------------------------------------------------------------------------
|
|
415
|
+
// sync
|
|
416
|
+
// -------------------------------------------------------------------------
|
|
417
|
+
async sync(options, storage, signal) {
|
|
418
|
+
const cursor = this.resolveCursor(options.cursor);
|
|
419
|
+
const isFull = options.mode === "full";
|
|
420
|
+
const phases = this.activePhases();
|
|
421
|
+
return paginateChunked({
|
|
422
|
+
phases,
|
|
423
|
+
cursor,
|
|
424
|
+
signal,
|
|
425
|
+
fetchPage: async (phase, page, sig) => {
|
|
426
|
+
switch (phase) {
|
|
427
|
+
case "issues":
|
|
428
|
+
return this.fetchIssuesPage(page, options, sig);
|
|
429
|
+
case "releases":
|
|
430
|
+
return this.fetchReleasesPage(page, sig);
|
|
431
|
+
case "error_stats":
|
|
432
|
+
return this.fetchErrorStats(sig);
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
writeBatch: async (phase, items, page) => {
|
|
436
|
+
if (isFull && page === null) {
|
|
437
|
+
switch (phase) {
|
|
438
|
+
case "issues":
|
|
439
|
+
if (this.isResourceEnabled("issues")) {
|
|
440
|
+
await storage.entities([], { types: ["sentry_issue"] });
|
|
441
|
+
}
|
|
442
|
+
if (this.isResourceEnabled("issue_events")) {
|
|
443
|
+
await storage.events([], { names: ["sentry_issue_event"] });
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
446
|
+
case "releases":
|
|
447
|
+
await storage.entities([], { types: ["sentry_release"] });
|
|
448
|
+
break;
|
|
449
|
+
case "error_stats":
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
switch (phase) {
|
|
454
|
+
case "issues":
|
|
455
|
+
for (const item of items) {
|
|
456
|
+
await this.writeIssuesPage(storage, item);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
case "releases":
|
|
460
|
+
return this.writeReleases(storage, items);
|
|
461
|
+
case "error_stats":
|
|
462
|
+
for (const stats of items) {
|
|
463
|
+
await this.writeErrorStats(storage, stats);
|
|
464
|
+
}
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// src/index.ts
|
|
473
|
+
var index_default = SentryConnector;
|
|
474
|
+
export {
|
|
475
|
+
SentryConnector,
|
|
476
|
+
configFields,
|
|
477
|
+
index_default as default
|
|
478
|
+
};
|
|
479
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/pagination.ts","../src/sentry.ts","../src/index.ts"],"sourcesContent":["import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(res: Response, parseJson: boolean): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport const githubRateLimit: RateLimitPolicy = {\n parse(h) {\n const remainingRaw = h.get('x-ratelimit-remaining');\n const resetRaw = h.get('x-ratelimit-reset');\n if (remainingRaw === null || resetRaw === null) {\n return null;\n }\n const remaining = Number(remainingRaw);\n const reset = Number(resetRaw);\n if (!Number.isFinite(remaining) || !Number.isFinite(reset) || reset < 0) {\n return null;\n }\n return { remaining, resetAt: new Date(reset * 1000) };\n },\n};\n\nexport const sentryRateLimit: RateLimitPolicy = {\n parse(h) {\n const concurrent = h.get('x-sentry-rate-limit-remaining');\n const reset = h.get('x-sentry-rate-limit-reset');\n if (concurrent === null || reset === null) {\n return null;\n }\n const remaining = Number(concurrent);\n const resetSec = Number(reset);\n if (\n !Number.isFinite(remaining) ||\n !Number.isFinite(resetSec) ||\n resetSec < 0\n ) {\n return null;\n }\n return { remaining, resetAt: new Date(resetSec * 1000) };\n },\n};\n\nexport const linearRateLimit: RateLimitPolicy = {\n parse(h) {\n const remainingRaw = h.get('x-ratelimit-requests-remaining');\n const resetRaw = h.get('x-ratelimit-requests-reset');\n if (remainingRaw === null) {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n let resetAt: Date;\n if (resetRaw !== null) {\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n resetAt = new Date(reset);\n } else {\n resetAt = new Date(Date.now() + 60_000);\n }\n return { remaining, resetAt };\n },\n};\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","import { type HttpResponse, sentryRateLimit } from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ChunkedSyncCursor,\n type ConnectorContext,\n type CredentialsSchema,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n paginateChunked,\n} from '@rawdash/core';\nimport { z } from 'zod';\n\n// ---------------------------------------------------------------------------\n// configFields\n// ---------------------------------------------------------------------------\n\nexport const configFields = defineConfigFields(\n z.object({\n authToken: z.object({ $secret: z.string() }).meta({\n label: 'Auth Token',\n description:\n 'Sentry Internal Integration token or User Auth Token. Create one at Sentry → Settings → Auth Tokens (or for an org, Settings → Custom Integrations → New Internal Integration).',\n placeholder: 'sntrys_...',\n secret: true,\n }),\n organization: z.string().min(1).meta({\n label: 'Organization slug',\n description: \"Your Sentry organization's slug, as it appears in the URL.\",\n placeholder: 'acme',\n }),\n projects: z.array(z.string().min(1)).nonempty().optional().meta({\n label: 'Projects (optional)',\n description:\n 'Restrict the sync to specific Sentry project slugs (or numeric IDs). Omit to sync every project the token can see.',\n }),\n resources: z\n .array(z.enum(['issues', 'issue_events', 'releases', 'errors_per_hour']))\n .nonempty()\n .optional()\n .meta({\n label: 'Resources',\n description:\n \"Which Sentry resources to sync. Omit to sync all of them. 'issue_events' depends on 'issues' being fetched — enabling it without 'issues' still runs the issues query, but skips writing issue entities.\",\n }),\n eventsPerIssueCap: z.number().int().positive().max(100).optional().meta({\n label: 'Events per issue cap',\n description:\n 'Maximum number of recent events (occurrences) to sample per issue on each sync. Defaults to 100 (the max page size Sentry allows for the issue events endpoint).',\n placeholder: '100',\n }),\n statsLookbackHours: z.number().int().positive().max(168).optional().meta({\n label: 'Stats lookback (hours)',\n description:\n 'How many hours of hourly error-rate data to refresh on each sync. Defaults to 24.',\n placeholder: '24',\n }),\n }),\n);\n\nexport type SentryResource =\n | 'issues'\n | 'issue_events'\n | 'releases'\n | 'errors_per_hour';\n\nexport interface SentrySettings {\n organization: string;\n projects?: readonly string[];\n resources?: readonly SentryResource[];\n eventsPerIssueCap?: number;\n statsLookbackHours?: number;\n}\n\nconst sentryCredentials = {\n authToken: {\n description: 'Sentry auth token',\n auth: 'required' as const,\n },\n} satisfies CredentialsSchema;\n\ntype SentryCredentials = typeof sentryCredentials;\n\n// ---------------------------------------------------------------------------\n// Sync phases + cursor\n// ---------------------------------------------------------------------------\n\nconst PHASE_ORDER = ['issues', 'releases', 'error_stats'] as const;\n\ntype SentryPhase = (typeof PHASE_ORDER)[number];\n\ntype SentrySyncCursor = ChunkedSyncCursor<SentryPhase, string>;\n\nfunction isSentrySyncCursor(value: unknown): value is SentrySyncCursor {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as { phase?: unknown; page?: unknown };\n if (typeof v.phase !== 'string') {\n return false;\n }\n if (!(PHASE_ORDER as readonly string[]).includes(v.phase)) {\n return false;\n }\n if (v.page !== null && typeof v.page !== 'string') {\n return false;\n }\n return true;\n}\n\n// ---------------------------------------------------------------------------\n// Sentry API types\n// ---------------------------------------------------------------------------\n\ninterface SentryProjectRef {\n id?: string | number;\n slug: string;\n name?: string;\n}\n\ninterface SentryIssue {\n id: string;\n shortId: string;\n title: string;\n level: string;\n status: string;\n firstSeen: string;\n lastSeen: string;\n count: string | number;\n userCount: number;\n project: SentryProjectRef;\n}\n\ninterface SentryIssueEvent {\n id?: string;\n eventID?: string;\n dateCreated: string;\n message?: string | null;\n platform?: string | null;\n groupID?: string;\n environment?: string | null;\n}\n\ninterface SentryRelease {\n version: string;\n dateCreated: string;\n dateReleased: string | null;\n lastEvent: string | null;\n projects: SentryProjectRef[];\n}\n\ninterface SentryStatsResponse {\n intervals: string[];\n groups: Array<{\n by: Record<string, string | number>;\n totals?: Record<string, number>;\n series: Record<string, number[]>;\n }>;\n start?: string;\n end?: string;\n}\n\ninterface IssuesPageItem {\n issues: SentryIssue[];\n eventsByIssue: Map<string, SentryIssueEvent[]>;\n}\n\n// ---------------------------------------------------------------------------\n// Link header parsing — Sentry uses Web Linking RFC 5988 plus `results=\"...\"`\n// to indicate whether a given direction has more pages. parseLinkHeader from\n// connector-shared captures the URL but not the `results` flag, so we parse\n// the raw header here.\n// ---------------------------------------------------------------------------\n\ninterface SentryLink {\n url: string;\n hasResults: boolean;\n}\n\nfunction safeTimestamp(iso: string | null | undefined): number | null {\n if (iso === null || iso === undefined) {\n return null;\n }\n const ms = new Date(iso).getTime();\n return Number.isFinite(ms) ? ms : null;\n}\n\nfunction parseSentryLink(\n header: string | null,\n rel: string,\n): SentryLink | null {\n if (!header) {\n return null;\n }\n for (const part of header.split(',')) {\n const m = part.match(/<([^>]+)>\\s*;\\s*(.+)$/);\n if (!m) {\n continue;\n }\n const url = m[1]!;\n const attrs = m[2]!;\n const relMatch = attrs.match(/rel=\"([^\"]+)\"/);\n if (!relMatch || relMatch[1] !== rel) {\n continue;\n }\n const resultsMatch = attrs.match(/results=\"([^\"]+)\"/);\n const hasResults = resultsMatch ? resultsMatch[1] === 'true' : true;\n return { url, hasResults };\n }\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// SentryConnector\n// ---------------------------------------------------------------------------\n\nconst SENTRY_API_HOST = 'sentry.io';\nconst SENTRY_API_BASE = `https://${SENTRY_API_HOST}/api/0`;\nconst DEFAULT_EVENTS_PER_ISSUE = 100;\nconst DEFAULT_STATS_LOOKBACK_HOURS = 24;\nconst ISSUES_PAGE_SIZE = 100;\nconst RELEASES_PAGE_SIZE = 100;\n\nexport class SentryConnector extends BaseConnector<\n SentrySettings,\n SentryCredentials\n> {\n static readonly id = 'sentry';\n\n static create(input: unknown, ctx?: ConnectorContext): SentryConnector {\n const parsed = configFields.parse(input);\n return new SentryConnector(\n {\n organization: parsed.organization,\n projects: parsed.projects,\n resources: parsed.resources,\n eventsPerIssueCap: parsed.eventsPerIssueCap,\n statsLookbackHours: parsed.statsLookbackHours,\n },\n { authToken: parsed.authToken },\n ctx,\n );\n }\n\n readonly id = 'sentry';\n override readonly credentials = sentryCredentials;\n\n private buildHeaders(): Record<string, string> {\n return {\n Authorization: `Bearer ${this.creds.authToken}`,\n 'User-Agent': 'rawdash/connector-sentry (+https://rawdash.dev)',\n };\n }\n\n private fetch<T>(\n url: string,\n resource: string,\n signal?: AbortSignal,\n ): Promise<HttpResponse<T>> {\n return this.get<T>(url, {\n resource,\n headers: this.buildHeaders(),\n signal,\n rateLimit: sentryRateLimit,\n });\n }\n\n // -------------------------------------------------------------------------\n // Resource enablement\n // -------------------------------------------------------------------------\n\n private isResourceEnabled(resource: SentryResource): boolean {\n const enabled = this.settings.resources;\n if (!enabled || enabled.length === 0) {\n return true;\n }\n return enabled.includes(resource);\n }\n\n private activePhases(): SentryPhase[] {\n const phases: SentryPhase[] = [];\n if (\n this.isResourceEnabled('issues') ||\n this.isResourceEnabled('issue_events')\n ) {\n phases.push('issues');\n }\n if (this.isResourceEnabled('releases')) {\n phases.push('releases');\n }\n if (this.isResourceEnabled('errors_per_hour')) {\n phases.push('error_stats');\n }\n return phases;\n }\n\n // -------------------------------------------------------------------------\n // URL building + sanitization\n // -------------------------------------------------------------------------\n\n private allowedPagePath(phase: SentryPhase): string | null {\n const org = this.settings.organization;\n switch (phase) {\n case 'issues':\n return `/api/0/organizations/${org}/issues/`;\n case 'releases':\n return `/api/0/organizations/${org}/releases/`;\n case 'error_stats':\n return null;\n }\n }\n\n private sanitizePageUrl(\n phase: SentryPhase,\n pageUrl: string | null,\n ): string | null {\n if (pageUrl === null) {\n return null;\n }\n const allowedPath = this.allowedPagePath(phase);\n if (allowedPath === null) {\n return null;\n }\n try {\n const u = new URL(pageUrl);\n if (\n u.protocol !== 'https:' ||\n u.host !== SENTRY_API_HOST ||\n u.pathname !== allowedPath\n ) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n }\n\n private resolveCursor(cursor: unknown): SentrySyncCursor | undefined {\n if (!isSentrySyncCursor(cursor)) {\n return undefined;\n }\n return {\n phase: cursor.phase,\n page: this.sanitizePageUrl(cursor.phase, cursor.page),\n };\n }\n\n private buildInitialIssuesUrl(options: SyncOptions): string {\n const u = new URL(\n `${SENTRY_API_BASE}/organizations/${this.settings.organization}/issues/`,\n );\n u.searchParams.set('limit', String(ISSUES_PAGE_SIZE));\n u.searchParams.set('sort', 'date');\n for (const project of this.settings.projects ?? []) {\n u.searchParams.append('project', project);\n }\n if (options.mode === 'latest' && options.since) {\n u.searchParams.set('query', `lastSeen:>${options.since}`);\n }\n return u.toString();\n }\n\n private buildInitialReleasesUrl(): string {\n const u = new URL(\n `${SENTRY_API_BASE}/organizations/${this.settings.organization}/releases/`,\n );\n u.searchParams.set('per_page', String(RELEASES_PAGE_SIZE));\n for (const project of this.settings.projects ?? []) {\n u.searchParams.append('project', project);\n }\n return u.toString();\n }\n\n private buildStatsUrl(): string {\n const lookback =\n this.settings.statsLookbackHours ?? DEFAULT_STATS_LOOKBACK_HOURS;\n const u = new URL(\n `${SENTRY_API_BASE}/organizations/${this.settings.organization}/stats_v2/`,\n );\n u.searchParams.set('field', 'sum(quantity)');\n u.searchParams.set('category', 'error');\n u.searchParams.set('interval', '1h');\n u.searchParams.set('statsPeriod', `${lookback}h`);\n u.searchParams.append('groupBy', 'project');\n for (const project of this.settings.projects ?? []) {\n u.searchParams.append('project', project);\n }\n return u.toString();\n }\n\n private buildIssueEventsUrl(issueId: string): string {\n const cap = this.settings.eventsPerIssueCap ?? DEFAULT_EVENTS_PER_ISSUE;\n const u = new URL(`${SENTRY_API_BASE}/issues/${issueId}/events/`);\n u.searchParams.set(\n 'limit',\n String(Math.min(cap, DEFAULT_EVENTS_PER_ISSUE)),\n );\n return u.toString();\n }\n\n // -------------------------------------------------------------------------\n // Fetchers\n // -------------------------------------------------------------------------\n\n private async fetchIssuesPage(\n page: string | null,\n options: SyncOptions,\n signal: AbortSignal | undefined,\n ): Promise<{ items: IssuesPageItem[]; next: string | null }> {\n const url = page ?? this.buildInitialIssuesUrl(options);\n const res = await this.fetch<SentryIssue[]>(url, 'issues', signal);\n\n const nextLink = parseSentryLink(res.headers.get('link'), 'next');\n const next =\n nextLink && nextLink.hasResults\n ? this.sanitizePageUrl('issues', nextLink.url)\n : null;\n\n const eventsByIssue = new Map<string, SentryIssueEvent[]>();\n if (this.isResourceEnabled('issue_events')) {\n for (const issue of res.body) {\n signal?.throwIfAborted();\n const eventsRes = await this.fetch<SentryIssueEvent[]>(\n this.buildIssueEventsUrl(issue.id),\n 'issue_events',\n signal,\n );\n eventsByIssue.set(issue.id, eventsRes.body);\n }\n }\n\n return { items: [{ issues: res.body, eventsByIssue }], next };\n }\n\n private async fetchReleasesPage(\n page: string | null,\n signal: AbortSignal | undefined,\n ): Promise<{ items: SentryRelease[]; next: string | null }> {\n const url = page ?? this.buildInitialReleasesUrl();\n const res = await this.fetch<SentryRelease[]>(url, 'releases', signal);\n const nextLink = parseSentryLink(res.headers.get('link'), 'next');\n const next =\n nextLink && nextLink.hasResults\n ? this.sanitizePageUrl('releases', nextLink.url)\n : null;\n return { items: res.body, next };\n }\n\n private async fetchErrorStats(\n signal: AbortSignal | undefined,\n ): Promise<{ items: SentryStatsResponse[]; next: string | null }> {\n const res = await this.fetch<SentryStatsResponse>(\n this.buildStatsUrl(),\n 'error_stats',\n signal,\n );\n return { items: [res.body], next: null };\n }\n\n // -------------------------------------------------------------------------\n // Writers\n // -------------------------------------------------------------------------\n\n private async writeIssuesPage(\n storage: StorageHandle,\n item: IssuesPageItem,\n ): Promise<void> {\n const writeEntities = this.isResourceEnabled('issues');\n const writeEvents = this.isResourceEnabled('issue_events');\n\n for (const issue of item.issues) {\n if (writeEntities) {\n const count =\n typeof issue.count === 'string' ? Number(issue.count) : issue.count;\n const firstSeenMs = safeTimestamp(issue.firstSeen);\n const lastSeenMs = safeTimestamp(issue.lastSeen);\n if (firstSeenMs === null || lastSeenMs === null) {\n console.warn(\n `[connector-sentry] skipping issue ${issue.id} with unparseable firstSeen/lastSeen`,\n );\n } else {\n await storage.entity({\n type: 'sentry_issue',\n id: issue.id,\n attributes: {\n shortId: issue.shortId,\n title: issue.title,\n level: issue.level,\n status: issue.status,\n firstSeen: firstSeenMs,\n lastSeen: lastSeenMs,\n count: Number.isFinite(count) ? count : 0,\n userCount: issue.userCount,\n projectSlug: issue.project.slug,\n },\n updated_at: lastSeenMs,\n });\n }\n }\n\n if (writeEvents) {\n const events = item.eventsByIssue.get(issue.id) ?? [];\n for (const ev of events) {\n const eventId = ev.eventID ?? ev.id ?? null;\n if (eventId === null) {\n continue;\n }\n const startTs = safeTimestamp(ev.dateCreated);\n if (startTs === null) {\n continue;\n }\n await storage.event({\n name: 'sentry_issue_event',\n start_ts: startTs,\n end_ts: null,\n attributes: {\n eventId,\n issueId: issue.id,\n issueShortId: issue.shortId,\n projectSlug: issue.project.slug,\n level: issue.level,\n platform: ev.platform ?? null,\n environment: ev.environment ?? null,\n message: ev.message ?? null,\n },\n });\n }\n }\n }\n }\n\n private async writeReleases(\n storage: StorageHandle,\n releases: SentryRelease[],\n ): Promise<void> {\n for (const r of releases) {\n const createdMs = safeTimestamp(r.dateCreated);\n const releasedMs = safeTimestamp(r.dateReleased);\n const lastEventMs = safeTimestamp(r.lastEvent);\n if (createdMs === null) {\n console.warn(\n `[connector-sentry] skipping release ${r.version} with unparseable dateCreated`,\n );\n continue;\n }\n await storage.entity({\n type: 'sentry_release',\n id: r.version,\n attributes: {\n version: r.version,\n projects: r.projects.map((p) => p.slug),\n dateCreated: createdMs,\n dateReleased: releasedMs,\n lastEvent: lastEventMs,\n },\n updated_at: Math.max(createdMs, releasedMs ?? 0, lastEventMs ?? 0),\n });\n }\n }\n\n private async writeErrorStats(\n storage: StorageHandle,\n stats: SentryStatsResponse,\n ): Promise<void> {\n const samples: Array<{\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n }> = [];\n for (const group of stats.groups) {\n const project = group.by['project'];\n const projectKey = project !== undefined ? String(project) : 'unknown';\n const series = group.series['sum(quantity)'] ?? [];\n for (let i = 0; i < stats.intervals.length; i++) {\n const intervalIso = stats.intervals[i];\n const rawValue = series[i];\n if (intervalIso === undefined || rawValue === undefined) {\n continue;\n }\n const ts = new Date(intervalIso).getTime();\n const value = Number(rawValue);\n if (!Number.isFinite(ts) || !Number.isFinite(value)) {\n continue;\n }\n samples.push({\n name: 'sentry_errors_per_hour',\n ts,\n value,\n attributes: { project: projectKey },\n });\n }\n }\n await storage.metrics(samples, { names: ['sentry_errors_per_hour'] });\n }\n\n // -------------------------------------------------------------------------\n // sync\n // -------------------------------------------------------------------------\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const cursor = this.resolveCursor(options.cursor);\n const isFull = options.mode === 'full';\n const phases = this.activePhases();\n\n return paginateChunked<SentryPhase, string>({\n phases,\n cursor,\n signal,\n fetchPage: async (phase, page, sig) => {\n switch (phase) {\n case 'issues':\n return this.fetchIssuesPage(page, options, sig);\n case 'releases':\n return this.fetchReleasesPage(page, sig);\n case 'error_stats':\n return this.fetchErrorStats(sig);\n }\n },\n writeBatch: async (phase, items, page) => {\n if (isFull && page === null) {\n switch (phase) {\n case 'issues':\n if (this.isResourceEnabled('issues')) {\n await storage.entities([], { types: ['sentry_issue'] });\n }\n if (this.isResourceEnabled('issue_events')) {\n await storage.events([], { names: ['sentry_issue_event'] });\n }\n break;\n case 'releases':\n await storage.entities([], { types: ['sentry_release'] });\n break;\n case 'error_stats':\n break;\n }\n }\n switch (phase) {\n case 'issues':\n for (const item of items as IssuesPageItem[]) {\n await this.writeIssuesPage(storage, item);\n }\n return;\n case 'releases':\n return this.writeReleases(storage, items as SentryRelease[]);\n case 'error_stats':\n for (const stats of items as SentryStatsResponse[]) {\n await this.writeErrorStats(storage, stats);\n }\n return;\n }\n },\n });\n }\n}\n","import { SentryConnector } from './sentry';\n\nexport { configFields, SentryConnector } from './sentry';\nexport type { SentryResource, SentrySettings } from './sentry';\nexport default SentryConnector;\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rawdash/connector-sentry",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Rawdash connector for Sentry — issues, issue events, releases, and error-rate metrics",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/rawdash/rawdash.git",
|
|
10
|
+
"directory": "packages/connectors/sentry"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"@rawdash/source": "./src/index.ts",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"lint": "eslint src",
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@rawdash/core": "workspace:*",
|
|
32
|
+
"zod": "^4.4.3"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@rawdash/connector-shared": "workspace:*",
|
|
36
|
+
"@rawdash/connector-test-utils": "workspace:*",
|
|
37
|
+
"fast-check": "^4.8.0",
|
|
38
|
+
"tsup": "^8.0.0",
|
|
39
|
+
"typescript": "^5.7.2",
|
|
40
|
+
"vitest": "^4.1.4"
|
|
41
|
+
}
|
|
42
|
+
}
|