@rawdash/connector-sentry 0.15.0 → 0.16.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 +64 -128
- package/dist/index.d.ts +96 -4
- package/dist/index.js +190 -86
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,70 +1,81 @@
|
|
|
1
|
+
<!-- This file is generated from connector metadata by scripts/generate-connector-docs.ts. Do not edit by hand. -->
|
|
2
|
+
|
|
1
3
|
# @rawdash/connector-sentry
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@rawdash/connector-sentry)
|
|
6
|
+
[](https://github.com/rawdash/rawdash/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
Sync issues, issue events, releases, and hourly error rates from a Sentry organization.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
4
11
|
|
|
5
|
-
|
|
12
|
+
```sh
|
|
13
|
+
npm install @rawdash/connector-sentry
|
|
14
|
+
```
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
## Authentication
|
|
8
17
|
|
|
9
|
-
|
|
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.
|
|
18
|
+
A Sentry auth token is required. Use an organization-level Internal Integration token or a User Auth Token with read access to issues, events, and releases.
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
1. Open Sentry → Settings → Custom Integrations → New Internal Integration (or Settings → Auth Tokens for a personal token).
|
|
21
|
+
2. Grant read access to Issues & Events and Releases.
|
|
22
|
+
3. Copy the generated token and store it as a secret, referencing it from the connector config as `authToken: secret("SENTRY_AUTH_TOKEN")`.
|
|
23
|
+
4. Set the `organization` slug as it appears in your Sentry URL.
|
|
13
24
|
|
|
14
25
|
## Configuration
|
|
15
26
|
|
|
27
|
+
| Field | Type | Required | Description |
|
|
28
|
+
| -------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `authToken` | secret | Yes | 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). |
|
|
30
|
+
| `organization` | string | Yes | Your Sentry organization's slug, as it appears in the URL. |
|
|
31
|
+
| `projects` | array | No | Restrict the sync to specific Sentry project slugs (or numeric IDs). Omit to sync every project the token can see. |
|
|
32
|
+
| `resources` | array | No | 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. |
|
|
33
|
+
| `eventsPerIssueCap` | number | No | 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). |
|
|
34
|
+
| `statsLookbackHours` | number | No | How many hours of hourly error-rate data to refresh on each sync. Defaults to 24. |
|
|
35
|
+
|
|
36
|
+
## Resources
|
|
37
|
+
|
|
38
|
+
- **`sentry_issue`** _(entity)_ - Sentry issues (error groups) with level, status, occurrence count, affected user count, and first/last seen timestamps.
|
|
39
|
+
- Endpoint: `GET /api/0/organizations/{organization}/issues/`
|
|
40
|
+
- **`sentry_issue_event`** _(event)_ - Individual event occurrences sampled per issue, with platform, environment, level, and message.
|
|
41
|
+
- Endpoint: `GET /api/0/issues/{issueId}/events/`
|
|
42
|
+
- Events are sampled: at most eventsPerIssueCap recent events per issue per sync (Sentry caps a single events page at 100), so this is a representative sample, not a full audit trail.
|
|
43
|
+
- **`sentry_release`** _(entity)_ - Releases with their versions, associated project slugs, and creation/release/last-event timestamps.
|
|
44
|
+
- Endpoint: `GET /api/0/organizations/{organization}/releases/`
|
|
45
|
+
- **`sentry_errors_per_hour`** _(metric)_ - Hourly count of error events, broken down by project, over the configured lookback window.
|
|
46
|
+
- Endpoint: `GET /api/0/organizations/{organization}/stats_v2/`
|
|
47
|
+
- Unit: errors
|
|
48
|
+
- Granularity: 1h
|
|
49
|
+
- Dimensions: `project`
|
|
50
|
+
|
|
51
|
+
## Example
|
|
52
|
+
|
|
16
53
|
```ts
|
|
17
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
defineConfig,
|
|
56
|
+
defineDashboard,
|
|
57
|
+
defineMetric,
|
|
58
|
+
secret,
|
|
59
|
+
} from '@rawdash/core';
|
|
18
60
|
|
|
19
61
|
const sentry = {
|
|
20
62
|
name: 'sentry',
|
|
21
63
|
connectorId: 'sentry',
|
|
22
64
|
config: {
|
|
23
|
-
organization: 'acme',
|
|
24
65
|
authToken: secret('SENTRY_AUTH_TOKEN'),
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// eventsPerIssueCap: 100, // optional — max events sampled per issue (default 100)
|
|
28
|
-
// statsLookbackHours: 24, // optional — hours of hourly stats refreshed per sync (default 24)
|
|
66
|
+
organization: 'my-org',
|
|
67
|
+
projects: ['my-project'],
|
|
29
68
|
},
|
|
30
69
|
};
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Register the connector class when mounting the engine:
|
|
34
|
-
|
|
35
|
-
```ts
|
|
36
|
-
import { SentryConnector } from '@rawdash/connector-sentry';
|
|
37
|
-
import { mountEngine } from '@rawdash/hono';
|
|
38
|
-
|
|
39
|
-
mountEngine(config, { connectorRegistry: { sentry: SentryConnector } });
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### Choosing resources
|
|
43
|
-
|
|
44
|
-
The connector exposes four resources, written across three internal sync phases:
|
|
45
|
-
|
|
46
|
-
| Resource | Phase | What gets written |
|
|
47
|
-
| ----------------- | ----------- | --------------------------------------------------------------------------------- |
|
|
48
|
-
| `issues` | issues | `sentry_issue` entities, one per Sentry group |
|
|
49
|
-
| `issue_events` | issues | `sentry_issue_event` events, sampled per issue (`eventsPerIssueCap`, default 100) |
|
|
50
|
-
| `releases` | releases | `sentry_release` entities |
|
|
51
|
-
| `errors_per_hour` | error_stats | `sentry_errors_per_hour` metric samples, hourly, per project |
|
|
52
|
-
|
|
53
|
-
`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.
|
|
54
|
-
|
|
55
|
-
### Example dashboard
|
|
56
|
-
|
|
57
|
-
```ts
|
|
58
|
-
import { defineConfig, defineDashboard, defineMetric } from '@rawdash/core';
|
|
59
70
|
|
|
60
71
|
export default defineConfig({
|
|
61
72
|
connectors: [sentry],
|
|
62
73
|
dashboards: {
|
|
63
|
-
|
|
74
|
+
engineering: defineDashboard({
|
|
64
75
|
widgets: {
|
|
65
76
|
unresolved_issues: {
|
|
66
77
|
kind: 'stat',
|
|
67
|
-
title: 'Unresolved
|
|
78
|
+
title: 'Unresolved Issues',
|
|
68
79
|
metric: defineMetric({
|
|
69
80
|
connector: sentry,
|
|
70
81
|
shape: 'entity',
|
|
@@ -73,102 +84,27 @@ export default defineConfig({
|
|
|
73
84
|
filter: [{ field: 'status', op: 'eq', value: 'unresolved' }],
|
|
74
85
|
}),
|
|
75
86
|
},
|
|
76
|
-
errors_per_hour: {
|
|
77
|
-
kind: 'timeseries',
|
|
78
|
-
title: 'Errors per hour',
|
|
79
|
-
window: '24h',
|
|
80
|
-
metric: defineMetric({
|
|
81
|
-
connector: sentry,
|
|
82
|
-
shape: 'metric',
|
|
83
|
-
name: 'sentry_errors_per_hour',
|
|
84
|
-
fn: 'sum',
|
|
85
|
-
window: '24h',
|
|
86
|
-
groupBy: { field: 'ts', granularity: 'hour' },
|
|
87
|
-
}),
|
|
88
|
-
},
|
|
89
|
-
issues_by_level: {
|
|
90
|
-
kind: 'distribution',
|
|
91
|
-
title: 'Issues by level',
|
|
92
|
-
metric: defineMetric({
|
|
93
|
-
connector: sentry,
|
|
94
|
-
shape: 'entity',
|
|
95
|
-
entityType: 'sentry_issue',
|
|
96
|
-
fn: 'count',
|
|
97
|
-
groupBy: { field: 'level' },
|
|
98
|
-
}),
|
|
99
|
-
},
|
|
100
87
|
},
|
|
101
88
|
}),
|
|
102
89
|
},
|
|
103
90
|
});
|
|
104
91
|
```
|
|
105
92
|
|
|
106
|
-
##
|
|
107
|
-
|
|
108
|
-
| Storage shape | Entity/event/metric type | Key attributes |
|
|
109
|
-
| ------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
110
|
-
| entity | `sentry_issue` | shortId, title, level, status, firstSeen, lastSeen, count, userCount, projectSlug |
|
|
111
|
-
| event | `sentry_issue_event` | eventId, issueId, issueShortId, projectSlug, level, platform, environment, message |
|
|
112
|
-
| entity | `sentry_release` | version, projects, dateCreated, dateReleased, lastEvent |
|
|
113
|
-
| metric | `sentry_errors_per_hour` | value = error count for the hour; attributes = `{ project }`; one sample per (project, hour-aligned ts) |
|
|
114
|
-
|
|
115
|
-
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.
|
|
116
|
-
|
|
117
|
-
## Schemas
|
|
118
|
-
|
|
119
|
-
`SentryConnector.schemas` declares the Zod schema for each resource's raw API response. Used by the cloud shape-drift pipeline to populate `connector_baselines`, and by the package's property tests.
|
|
93
|
+
## Rate limits
|
|
120
94
|
|
|
121
|
-
|
|
122
|
-
| -------------- | ---------------------------------------------------------------- |
|
|
123
|
-
| `issues` | `GET /api/0/organizations/{org}/issues/` page |
|
|
124
|
-
| `issue_events` | `GET /api/0/issues/{id}/events/` page |
|
|
125
|
-
| `releases` | `GET /api/0/organizations/{org}/releases/` page |
|
|
126
|
-
| `error_stats` | `GET /api/0/organizations/{org}/stats_v2/` — hourly error counts |
|
|
95
|
+
Sentry returns X-Sentry-Rate-Limit-Remaining / X-Sentry-Rate-Limit-Reset headers (reset in seconds); list pagination uses the Link header (page size 100).
|
|
127
96
|
|
|
128
|
-
##
|
|
97
|
+
## Limitations
|
|
129
98
|
|
|
130
|
-
-
|
|
131
|
-
-
|
|
132
|
-
- **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.
|
|
133
|
-
- **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.
|
|
134
|
-
|
|
135
|
-
## Errors
|
|
136
|
-
|
|
137
|
-
`@rawdash/connector-shared` maps Sentry's HTTP responses to typed errors automatically:
|
|
138
|
-
|
|
139
|
-
- `401` / `403` → `AuthError` — host stops syncing until the token is replaced.
|
|
140
|
-
- `429` → `RateLimitError` — host backs off and reschedules.
|
|
141
|
-
- `5xx` → `TransientError` — host retries on the next tick.
|
|
142
|
-
|
|
143
|
-
## Out of scope (post-MVP)
|
|
144
|
-
|
|
145
|
-
- Performance / Trace data — high cost, low signal for the launch dashboard set.
|
|
146
|
-
- Per-event payloads (stack traces, breadcrumbs) — the connector counts events but does not store their payloads.
|
|
147
|
-
- Self-hosted Sentry instances on custom hosts — pagination URLs are pinned to `sentry.io`.
|
|
148
|
-
|
|
149
|
-
## Registering in the MCP server
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
import { SentryConnector, configFields } from '@rawdash/connector-sentry';
|
|
153
|
-
|
|
154
|
-
createMcpServer({
|
|
155
|
-
// ...
|
|
156
|
-
connectorFactories: [
|
|
157
|
-
{
|
|
158
|
-
id: 'sentry',
|
|
159
|
-
configFields,
|
|
160
|
-
create: SentryConnector.create,
|
|
161
|
-
},
|
|
162
|
-
],
|
|
163
|
-
});
|
|
164
|
-
```
|
|
99
|
+
- Performance / trace data is out of scope (high cost, low signal for dashboards).
|
|
100
|
+
- Self-hosted Sentry on custom hosts is out of scope (pagination URLs are pinned to sentry.io).
|
|
165
101
|
|
|
166
|
-
##
|
|
102
|
+
## Links
|
|
167
103
|
|
|
168
|
-
|
|
104
|
+
- [Rawdash docs](https://rawdash.dev/docs/connectors/)
|
|
105
|
+
- [Sentry API docs](https://docs.sentry.io/api/)
|
|
106
|
+
- [GitHub](https://github.com/rawdash/rawdash)
|
|
169
107
|
|
|
170
|
-
|
|
171
|
-
2. Pipe them through `connector.sync()` against an `InMemoryStorage` instance.
|
|
172
|
-
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.
|
|
108
|
+
## License
|
|
173
109
|
|
|
174
|
-
|
|
110
|
+
Apache-2.0
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseConnector, ConnectorContext, SyncOptions, StorageHandle, SyncResult } from '@rawdash/core';
|
|
1
|
+
import { BaseConnector, ConnectorContext, SyncOptions, StorageHandle, SyncResult, ConnectorDoc } from '@rawdash/core';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
|
|
4
4
|
declare const configFields: z.ZodObject<{
|
|
@@ -16,6 +16,7 @@ declare const configFields: z.ZodObject<{
|
|
|
16
16
|
eventsPerIssueCap: z.ZodOptional<z.ZodNumber>;
|
|
17
17
|
statsLookbackHours: z.ZodOptional<z.ZodNumber>;
|
|
18
18
|
}, z.core.$strip>;
|
|
19
|
+
declare const doc: ConnectorDoc;
|
|
19
20
|
type SentryResource = 'issues' | 'issue_events' | 'releases' | 'errors_per_hour';
|
|
20
21
|
interface SentrySettings {
|
|
21
22
|
organization: string;
|
|
@@ -33,6 +34,95 @@ declare const sentryCredentials: {
|
|
|
33
34
|
type SentryCredentials = typeof sentryCredentials;
|
|
34
35
|
declare class SentryConnector extends BaseConnector<SentrySettings, SentryCredentials> {
|
|
35
36
|
static readonly id = "sentry";
|
|
37
|
+
static readonly resources: {
|
|
38
|
+
readonly sentry_issue: {
|
|
39
|
+
readonly shape: "entity";
|
|
40
|
+
readonly description: "Sentry issues (error groups) with level, status, occurrence count, affected user count, and first/last seen timestamps.";
|
|
41
|
+
readonly endpoint: "GET /api/0/organizations/{organization}/issues/";
|
|
42
|
+
readonly responses: {
|
|
43
|
+
readonly issues: z.ZodArray<z.ZodObject<{
|
|
44
|
+
id: z.ZodString;
|
|
45
|
+
shortId: z.ZodString;
|
|
46
|
+
title: z.ZodString;
|
|
47
|
+
level: z.ZodEnum<{
|
|
48
|
+
error: "error";
|
|
49
|
+
debug: "debug";
|
|
50
|
+
info: "info";
|
|
51
|
+
warning: "warning";
|
|
52
|
+
fatal: "fatal";
|
|
53
|
+
}>;
|
|
54
|
+
status: z.ZodEnum<{
|
|
55
|
+
resolved: "resolved";
|
|
56
|
+
unresolved: "unresolved";
|
|
57
|
+
ignored: "ignored";
|
|
58
|
+
}>;
|
|
59
|
+
firstSeen: z.ZodISODateTime;
|
|
60
|
+
lastSeen: z.ZodISODateTime;
|
|
61
|
+
count: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
|
|
62
|
+
userCount: z.ZodNumber;
|
|
63
|
+
project: z.ZodObject<{
|
|
64
|
+
slug: z.ZodString;
|
|
65
|
+
}, z.core.$strip>;
|
|
66
|
+
}, z.core.$strip>>;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
readonly sentry_issue_event: {
|
|
70
|
+
readonly shape: "event";
|
|
71
|
+
readonly description: "Individual event occurrences sampled per issue, with platform, environment, level, and message.";
|
|
72
|
+
readonly endpoint: "GET /api/0/issues/{issueId}/events/";
|
|
73
|
+
readonly notes: "Events are sampled: at most eventsPerIssueCap recent events per issue per sync (Sentry caps a single events page at 100), so this is a representative sample, not a full audit trail.";
|
|
74
|
+
readonly responses: {
|
|
75
|
+
readonly issue_events: z.ZodArray<z.ZodObject<{
|
|
76
|
+
id: z.ZodOptional<z.ZodString>;
|
|
77
|
+
eventID: z.ZodOptional<z.ZodString>;
|
|
78
|
+
dateCreated: z.ZodISODateTime;
|
|
79
|
+
message: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
80
|
+
platform: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
81
|
+
groupID: z.ZodOptional<z.ZodString>;
|
|
82
|
+
environment: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
83
|
+
}, z.core.$strip>>;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
readonly sentry_release: {
|
|
87
|
+
readonly shape: "entity";
|
|
88
|
+
readonly description: "Releases with their versions, associated project slugs, and creation/release/last-event timestamps.";
|
|
89
|
+
readonly endpoint: "GET /api/0/organizations/{organization}/releases/";
|
|
90
|
+
readonly responses: {
|
|
91
|
+
readonly releases: z.ZodArray<z.ZodObject<{
|
|
92
|
+
version: z.ZodString;
|
|
93
|
+
dateCreated: z.ZodISODateTime;
|
|
94
|
+
dateReleased: z.ZodNullable<z.ZodISODateTime>;
|
|
95
|
+
lastEvent: z.ZodNullable<z.ZodISODateTime>;
|
|
96
|
+
projects: z.ZodArray<z.ZodObject<{
|
|
97
|
+
slug: z.ZodString;
|
|
98
|
+
}, z.core.$strip>>;
|
|
99
|
+
}, z.core.$strip>>;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
readonly sentry_errors_per_hour: {
|
|
103
|
+
readonly shape: "metric";
|
|
104
|
+
readonly description: "Hourly count of error events, broken down by project, over the configured lookback window.";
|
|
105
|
+
readonly endpoint: "GET /api/0/organizations/{organization}/stats_v2/";
|
|
106
|
+
readonly unit: "errors";
|
|
107
|
+
readonly granularity: "1h";
|
|
108
|
+
readonly dimensions: [{
|
|
109
|
+
readonly name: "project";
|
|
110
|
+
readonly description: "Sentry project slug or id.";
|
|
111
|
+
}];
|
|
112
|
+
readonly responses: {
|
|
113
|
+
readonly error_stats: z.ZodObject<{
|
|
114
|
+
intervals: z.ZodArray<z.ZodISODateTime>;
|
|
115
|
+
groups: z.ZodArray<z.ZodObject<{
|
|
116
|
+
by: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
|
|
117
|
+
totals: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
118
|
+
series: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodNumber>>;
|
|
119
|
+
}, z.core.$strip>>;
|
|
120
|
+
start: z.ZodOptional<z.ZodString>;
|
|
121
|
+
end: z.ZodOptional<z.ZodString>;
|
|
122
|
+
}, z.core.$strip>;
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
};
|
|
36
126
|
static readonly schemas: {
|
|
37
127
|
readonly issues: z.ZodArray<z.ZodObject<{
|
|
38
128
|
id: z.ZodString;
|
|
@@ -58,6 +148,7 @@ declare class SentryConnector extends BaseConnector<SentrySettings, SentryCreden
|
|
|
58
148
|
slug: z.ZodString;
|
|
59
149
|
}, z.core.$strip>;
|
|
60
150
|
}, z.core.$strip>>;
|
|
151
|
+
} & {
|
|
61
152
|
readonly issue_events: z.ZodArray<z.ZodObject<{
|
|
62
153
|
id: z.ZodOptional<z.ZodString>;
|
|
63
154
|
eventID: z.ZodOptional<z.ZodString>;
|
|
@@ -67,6 +158,7 @@ declare class SentryConnector extends BaseConnector<SentrySettings, SentryCreden
|
|
|
67
158
|
groupID: z.ZodOptional<z.ZodString>;
|
|
68
159
|
environment: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
69
160
|
}, z.core.$strip>>;
|
|
161
|
+
} & {
|
|
70
162
|
readonly releases: z.ZodArray<z.ZodObject<{
|
|
71
163
|
version: z.ZodString;
|
|
72
164
|
dateCreated: z.ZodISODateTime;
|
|
@@ -76,6 +168,7 @@ declare class SentryConnector extends BaseConnector<SentrySettings, SentryCreden
|
|
|
76
168
|
slug: z.ZodString;
|
|
77
169
|
}, z.core.$strip>>;
|
|
78
170
|
}, z.core.$strip>>;
|
|
171
|
+
} & {
|
|
79
172
|
readonly error_stats: z.ZodObject<{
|
|
80
173
|
intervals: z.ZodArray<z.ZodISODateTime>;
|
|
81
174
|
groups: z.ZodArray<z.ZodObject<{
|
|
@@ -86,7 +179,7 @@ declare class SentryConnector extends BaseConnector<SentrySettings, SentryCreden
|
|
|
86
179
|
start: z.ZodOptional<z.ZodString>;
|
|
87
180
|
end: z.ZodOptional<z.ZodString>;
|
|
88
181
|
}, z.core.$strip>;
|
|
89
|
-
}
|
|
182
|
+
} & Readonly<Record<string, z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>>;
|
|
90
183
|
static create(input: unknown, ctx?: ConnectorContext): SentryConnector;
|
|
91
184
|
readonly id = "sentry";
|
|
92
185
|
readonly credentials: {
|
|
@@ -97,7 +190,6 @@ declare class SentryConnector extends BaseConnector<SentrySettings, SentryCreden
|
|
|
97
190
|
};
|
|
98
191
|
private buildHeaders;
|
|
99
192
|
private fetch;
|
|
100
|
-
private isResourceEnabled;
|
|
101
193
|
private activePhases;
|
|
102
194
|
private allowedPagePath;
|
|
103
195
|
private sanitizePageUrl;
|
|
@@ -115,4 +207,4 @@ declare class SentryConnector extends BaseConnector<SentrySettings, SentryCreden
|
|
|
115
207
|
sync(options: SyncOptions, storage: StorageHandle, signal?: AbortSignal): Promise<SyncResult>;
|
|
116
208
|
}
|
|
117
209
|
|
|
118
|
-
export { SentryConnector, type SentryResource, type SentrySettings, configFields, SentryConnector as default };
|
|
210
|
+
export { SentryConnector, type SentryResource, type SentrySettings, configFields, SentryConnector as default, doc };
|
package/dist/index.js
CHANGED
|
@@ -1,27 +1,94 @@
|
|
|
1
1
|
// ../../connector-shared/dist/index.js
|
|
2
2
|
var HTTP_CLIENT_VERSION = "0.0.0";
|
|
3
3
|
var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
function connectorUserAgent(connectorId) {
|
|
5
|
+
return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
|
|
6
|
+
}
|
|
7
|
+
function standardRateLimitPolicy(config) {
|
|
8
|
+
const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;
|
|
9
|
+
const multiplier = resetUnit === "s" ? 1e3 : 1;
|
|
10
|
+
return {
|
|
11
|
+
parse(h) {
|
|
12
|
+
const remainingRaw = h.get(remainingHeader);
|
|
13
|
+
if (remainingRaw === null || remainingRaw.trim() === "") {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const remaining = Number(remainingRaw);
|
|
17
|
+
if (!Number.isFinite(remaining)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const resetRaw = h.get(resetHeader);
|
|
21
|
+
if (resetRaw === null) {
|
|
22
|
+
if (resetFallbackMs === void 0) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
remaining,
|
|
27
|
+
resetAt: new Date(Date.now() + resetFallbackMs)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (resetRaw.trim() === "") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const reset = Number(resetRaw);
|
|
34
|
+
if (!Number.isFinite(reset) || reset < 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const resetMs = reset * multiplier;
|
|
38
|
+
if (!Number.isFinite(resetMs)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return { remaining, resetAt: new Date(resetMs) };
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function sanitizeAllowedUrl(options) {
|
|
46
|
+
const { url, host, pathname, protocol = "https:" } = options;
|
|
47
|
+
if (url === null) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const u = new URL(url);
|
|
52
|
+
if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {
|
|
9
53
|
return null;
|
|
10
54
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
55
|
+
return u.toString();
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function parseEpoch(value, unit) {
|
|
61
|
+
if (value === null || value === void 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (unit === "iso") {
|
|
65
|
+
if (typeof value !== "string") {
|
|
14
66
|
return null;
|
|
15
67
|
}
|
|
16
|
-
|
|
68
|
+
const ms = new Date(value).getTime();
|
|
69
|
+
return Number.isFinite(ms) ? ms : null;
|
|
17
70
|
}
|
|
18
|
-
|
|
71
|
+
if (typeof value === "string" && value.trim() === "") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
75
|
+
if (!Number.isFinite(n)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const result = unit === "s" ? n * 1e3 : n;
|
|
79
|
+
return Number.isFinite(result) ? result : null;
|
|
80
|
+
}
|
|
19
81
|
|
|
20
82
|
// src/sentry.ts
|
|
21
83
|
import {
|
|
22
84
|
BaseConnector,
|
|
23
85
|
defineConfigFields,
|
|
24
|
-
|
|
86
|
+
defineConnectorDoc,
|
|
87
|
+
defineResources,
|
|
88
|
+
makeChunkedCursorGuard,
|
|
89
|
+
paginateChunked,
|
|
90
|
+
schemasFromResources,
|
|
91
|
+
selectActivePhases
|
|
25
92
|
} from "@rawdash/core";
|
|
26
93
|
import { z } from "zod";
|
|
27
94
|
var configFields = defineConfigFields(
|
|
@@ -43,7 +110,7 @@ var configFields = defineConfigFields(
|
|
|
43
110
|
}),
|
|
44
111
|
resources: z.array(z.enum(["issues", "issue_events", "releases", "errors_per_hour"])).nonempty().optional().meta({
|
|
45
112
|
label: "Resources",
|
|
46
|
-
description: "Which Sentry resources to sync. Omit to sync all of them. 'issue_events' depends on 'issues' being fetched
|
|
113
|
+
description: "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."
|
|
47
114
|
}),
|
|
48
115
|
eventsPerIssueCap: z.number().int().positive().max(100).optional().meta({
|
|
49
116
|
label: "Events per issue cap",
|
|
@@ -57,36 +124,44 @@ var configFields = defineConfigFields(
|
|
|
57
124
|
})
|
|
58
125
|
})
|
|
59
126
|
);
|
|
127
|
+
var doc = defineConnectorDoc({
|
|
128
|
+
displayName: "Sentry",
|
|
129
|
+
category: "engineering",
|
|
130
|
+
brandColor: "#362D59",
|
|
131
|
+
tagline: "Sync issues, issue events, releases, and hourly error rates from a Sentry organization.",
|
|
132
|
+
vendor: {
|
|
133
|
+
name: "Sentry",
|
|
134
|
+
apiDocs: "https://docs.sentry.io/api/",
|
|
135
|
+
website: "https://sentry.io"
|
|
136
|
+
},
|
|
137
|
+
auth: {
|
|
138
|
+
summary: "A Sentry auth token is required. Use an organization-level Internal Integration token or a User Auth Token with read access to issues, events, and releases.",
|
|
139
|
+
setup: [
|
|
140
|
+
"Open Sentry \u2192 Settings \u2192 Custom Integrations \u2192 New Internal Integration (or Settings \u2192 Auth Tokens for a personal token).",
|
|
141
|
+
"Grant read access to Issues & Events and Releases.",
|
|
142
|
+
'Copy the generated token and store it as a secret, referencing it from the connector config as `authToken: secret("SENTRY_AUTH_TOKEN")`.',
|
|
143
|
+
"Set the `organization` slug as it appears in your Sentry URL."
|
|
144
|
+
]
|
|
145
|
+
},
|
|
146
|
+
rateLimit: "Sentry returns X-Sentry-Rate-Limit-Remaining / X-Sentry-Rate-Limit-Reset headers (reset in seconds); list pagination uses the Link header (page size 100).",
|
|
147
|
+
limitations: [
|
|
148
|
+
"Performance / trace data is out of scope (high cost, low signal for dashboards).",
|
|
149
|
+
"Self-hosted Sentry on custom hosts is out of scope (pagination URLs are pinned to sentry.io)."
|
|
150
|
+
]
|
|
151
|
+
});
|
|
60
152
|
var sentryCredentials = {
|
|
61
153
|
authToken: {
|
|
62
154
|
description: "Sentry auth token",
|
|
63
155
|
auth: "required"
|
|
64
156
|
}
|
|
65
157
|
};
|
|
158
|
+
var sentryRateLimit = standardRateLimitPolicy({
|
|
159
|
+
remainingHeader: "x-sentry-rate-limit-remaining",
|
|
160
|
+
resetHeader: "x-sentry-rate-limit-reset",
|
|
161
|
+
resetUnit: "s"
|
|
162
|
+
});
|
|
66
163
|
var PHASE_ORDER = ["issues", "releases", "error_stats"];
|
|
67
|
-
|
|
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
|
-
}
|
|
164
|
+
var isSentrySyncCursor = makeChunkedCursorGuard(PHASE_ORDER);
|
|
90
165
|
function parseSentryLink(header, rel) {
|
|
91
166
|
if (!header) {
|
|
92
167
|
return null;
|
|
@@ -161,14 +236,42 @@ var errorStatsResponseSchema = z.object({
|
|
|
161
236
|
start: z.string().optional(),
|
|
162
237
|
end: z.string().optional()
|
|
163
238
|
});
|
|
239
|
+
var sentryResources = defineResources({
|
|
240
|
+
sentry_issue: {
|
|
241
|
+
shape: "entity",
|
|
242
|
+
description: "Sentry issues (error groups) with level, status, occurrence count, affected user count, and first/last seen timestamps.",
|
|
243
|
+
endpoint: "GET /api/0/organizations/{organization}/issues/",
|
|
244
|
+
responses: { issues: issueResponseSchema }
|
|
245
|
+
},
|
|
246
|
+
sentry_issue_event: {
|
|
247
|
+
shape: "event",
|
|
248
|
+
description: "Individual event occurrences sampled per issue, with platform, environment, level, and message.",
|
|
249
|
+
endpoint: "GET /api/0/issues/{issueId}/events/",
|
|
250
|
+
notes: "Events are sampled: at most eventsPerIssueCap recent events per issue per sync (Sentry caps a single events page at 100), so this is a representative sample, not a full audit trail.",
|
|
251
|
+
responses: { issue_events: issueEventResponseSchema }
|
|
252
|
+
},
|
|
253
|
+
sentry_release: {
|
|
254
|
+
shape: "entity",
|
|
255
|
+
description: "Releases with their versions, associated project slugs, and creation/release/last-event timestamps.",
|
|
256
|
+
endpoint: "GET /api/0/organizations/{organization}/releases/",
|
|
257
|
+
responses: { releases: releaseResponseSchema }
|
|
258
|
+
},
|
|
259
|
+
sentry_errors_per_hour: {
|
|
260
|
+
shape: "metric",
|
|
261
|
+
description: "Hourly count of error events, broken down by project, over the configured lookback window.",
|
|
262
|
+
endpoint: "GET /api/0/organizations/{organization}/stats_v2/",
|
|
263
|
+
unit: "errors",
|
|
264
|
+
granularity: "1h",
|
|
265
|
+
dimensions: [
|
|
266
|
+
{ name: "project", description: "Sentry project slug or id." }
|
|
267
|
+
],
|
|
268
|
+
responses: { error_stats: errorStatsResponseSchema }
|
|
269
|
+
}
|
|
270
|
+
});
|
|
164
271
|
var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
165
272
|
static id = "sentry";
|
|
166
|
-
static
|
|
167
|
-
|
|
168
|
-
issue_events: issueEventResponseSchema,
|
|
169
|
-
releases: releaseResponseSchema,
|
|
170
|
-
error_stats: errorStatsResponseSchema
|
|
171
|
-
};
|
|
273
|
+
static resources = sentryResources;
|
|
274
|
+
static schemas = schemasFromResources(sentryResources);
|
|
172
275
|
static create(input, ctx) {
|
|
173
276
|
const parsed = configFields.parse(input);
|
|
174
277
|
return new _SentryConnector(
|
|
@@ -188,7 +291,7 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
188
291
|
buildHeaders() {
|
|
189
292
|
return {
|
|
190
293
|
Authorization: `Bearer ${this.creds.authToken}`,
|
|
191
|
-
"User-Agent": "
|
|
294
|
+
"User-Agent": connectorUserAgent("sentry")
|
|
192
295
|
};
|
|
193
296
|
}
|
|
194
297
|
fetch(url, resource, signal) {
|
|
@@ -202,25 +305,22 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
202
305
|
// -------------------------------------------------------------------------
|
|
203
306
|
// Resource enablement
|
|
204
307
|
// -------------------------------------------------------------------------
|
|
205
|
-
isResourceEnabled(resource) {
|
|
206
|
-
const enabled = this.settings.resources;
|
|
207
|
-
if (!enabled || enabled.length === 0) {
|
|
208
|
-
return true;
|
|
209
|
-
}
|
|
210
|
-
return enabled.includes(resource);
|
|
211
|
-
}
|
|
212
308
|
activePhases() {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
309
|
+
return selectActivePhases(
|
|
310
|
+
(r) => {
|
|
311
|
+
switch (r) {
|
|
312
|
+
case "issues":
|
|
313
|
+
case "issue_events":
|
|
314
|
+
return "issues";
|
|
315
|
+
case "releases":
|
|
316
|
+
return "releases";
|
|
317
|
+
case "errors_per_hour":
|
|
318
|
+
return "error_stats";
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
PHASE_ORDER,
|
|
322
|
+
this.settings.resources
|
|
323
|
+
);
|
|
224
324
|
}
|
|
225
325
|
// -------------------------------------------------------------------------
|
|
226
326
|
// URL building + sanitization
|
|
@@ -237,22 +337,15 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
237
337
|
}
|
|
238
338
|
}
|
|
239
339
|
sanitizePageUrl(phase, pageUrl) {
|
|
240
|
-
if (pageUrl === null) {
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
340
|
const allowedPath = this.allowedPagePath(phase);
|
|
244
341
|
if (allowedPath === null) {
|
|
245
342
|
return null;
|
|
246
343
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return u.toString();
|
|
253
|
-
} catch {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
344
|
+
return sanitizeAllowedUrl({
|
|
345
|
+
url: pageUrl,
|
|
346
|
+
host: SENTRY_API_HOST,
|
|
347
|
+
pathname: allowedPath
|
|
348
|
+
});
|
|
256
349
|
}
|
|
257
350
|
resolveCursor(cursor) {
|
|
258
351
|
if (!isSentrySyncCursor(cursor)) {
|
|
@@ -272,7 +365,7 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
272
365
|
for (const project of this.settings.projects ?? []) {
|
|
273
366
|
u.searchParams.append("project", project);
|
|
274
367
|
}
|
|
275
|
-
if (options.
|
|
368
|
+
if (options.since) {
|
|
276
369
|
u.searchParams.set("query", `lastSeen:>${options.since}`);
|
|
277
370
|
}
|
|
278
371
|
return u.toString();
|
|
@@ -333,12 +426,21 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
333
426
|
}
|
|
334
427
|
return { items: [{ issues: res.body, eventsByIssue }], next };
|
|
335
428
|
}
|
|
336
|
-
async fetchReleasesPage(page, signal) {
|
|
429
|
+
async fetchReleasesPage(page, options, signal) {
|
|
337
430
|
const url = page ?? this.buildInitialReleasesUrl();
|
|
338
431
|
const res = await this.fetch(url, "releases", signal);
|
|
339
432
|
const nextLink = parseSentryLink(res.headers.get("link"), "next");
|
|
340
|
-
const
|
|
341
|
-
|
|
433
|
+
const releases = res.body;
|
|
434
|
+
const cutoff = options.since ? new Date(options.since).getTime() : null;
|
|
435
|
+
const filtered = cutoff !== null ? releases.filter((r) => {
|
|
436
|
+
const ts = new Date(r.dateReleased ?? r.dateCreated).getTime();
|
|
437
|
+
return Number.isFinite(ts) ? ts >= cutoff : true;
|
|
438
|
+
}) : releases;
|
|
439
|
+
const lastRelease = releases.at(-1);
|
|
440
|
+
const lastTs = lastRelease ? new Date(lastRelease.dateReleased ?? lastRelease.dateCreated).getTime() : null;
|
|
441
|
+
const cutoffReached = cutoff !== null && lastTs !== null && Number.isFinite(lastTs) && lastTs < cutoff;
|
|
442
|
+
const next = !cutoffReached && nextLink && nextLink.hasResults ? this.sanitizePageUrl("releases", nextLink.url) : null;
|
|
443
|
+
return { items: filtered, next };
|
|
342
444
|
}
|
|
343
445
|
async fetchErrorStats(signal) {
|
|
344
446
|
const res = await this.fetch(
|
|
@@ -357,8 +459,8 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
357
459
|
for (const issue of item.issues) {
|
|
358
460
|
if (writeEntities) {
|
|
359
461
|
const count = typeof issue.count === "string" ? Number(issue.count) : issue.count;
|
|
360
|
-
const firstSeenMs =
|
|
361
|
-
const lastSeenMs =
|
|
462
|
+
const firstSeenMs = parseEpoch(issue.firstSeen, "iso");
|
|
463
|
+
const lastSeenMs = parseEpoch(issue.lastSeen, "iso");
|
|
362
464
|
if (firstSeenMs === null || lastSeenMs === null) {
|
|
363
465
|
console.warn(
|
|
364
466
|
`[connector-sentry] skipping issue ${issue.id} with unparseable firstSeen/lastSeen`
|
|
@@ -389,7 +491,7 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
389
491
|
if (eventId === null) {
|
|
390
492
|
continue;
|
|
391
493
|
}
|
|
392
|
-
const startTs =
|
|
494
|
+
const startTs = parseEpoch(ev.dateCreated, "iso");
|
|
393
495
|
if (startTs === null) {
|
|
394
496
|
continue;
|
|
395
497
|
}
|
|
@@ -414,9 +516,9 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
414
516
|
}
|
|
415
517
|
async writeReleases(storage, releases) {
|
|
416
518
|
for (const r of releases) {
|
|
417
|
-
const createdMs =
|
|
418
|
-
const releasedMs =
|
|
419
|
-
const lastEventMs =
|
|
519
|
+
const createdMs = parseEpoch(r.dateCreated, "iso");
|
|
520
|
+
const releasedMs = parseEpoch(r.dateReleased, "iso");
|
|
521
|
+
const lastEventMs = parseEpoch(r.lastEvent, "iso");
|
|
420
522
|
if (createdMs === null) {
|
|
421
523
|
console.warn(
|
|
422
524
|
`[connector-sentry] skipping release ${r.version} with unparseable dateCreated`
|
|
@@ -449,9 +551,9 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
449
551
|
if (intervalIso === void 0 || rawValue === void 0) {
|
|
450
552
|
continue;
|
|
451
553
|
}
|
|
452
|
-
const ts =
|
|
554
|
+
const ts = parseEpoch(intervalIso, "iso");
|
|
453
555
|
const value = Number(rawValue);
|
|
454
|
-
if (
|
|
556
|
+
if (ts === null || !Number.isFinite(value)) {
|
|
455
557
|
continue;
|
|
456
558
|
}
|
|
457
559
|
samples.push({
|
|
@@ -475,12 +577,13 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
|
|
|
475
577
|
phases,
|
|
476
578
|
cursor,
|
|
477
579
|
signal,
|
|
580
|
+
logger: this.logger,
|
|
478
581
|
fetchPage: async (phase, page, sig) => {
|
|
479
582
|
switch (phase) {
|
|
480
583
|
case "issues":
|
|
481
584
|
return this.fetchIssuesPage(page, options, sig);
|
|
482
585
|
case "releases":
|
|
483
|
-
return this.fetchReleasesPage(page, sig);
|
|
586
|
+
return this.fetchReleasesPage(page, options, sig);
|
|
484
587
|
case "error_stats":
|
|
485
588
|
return this.fetchErrorStats(sig);
|
|
486
589
|
}
|
|
@@ -527,6 +630,7 @@ var index_default = SentryConnector;
|
|
|
527
630
|
export {
|
|
528
631
|
SentryConnector,
|
|
529
632
|
configFields,
|
|
530
|
-
index_default as default
|
|
633
|
+
index_default as default,
|
|
634
|
+
doc
|
|
531
635
|
};
|
|
532
636
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +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\n// ---------------------------------------------------------------------------\n// Schemas — describe the per-resource API response shape consumed by request()\n// ---------------------------------------------------------------------------\n\nconst idString = z.string().min(1);\n\nconst issueResponseSchema = z.array(\n z.object({\n id: idString,\n shortId: z.string(),\n title: z.string(),\n level: z.enum(['debug', 'info', 'warning', 'error', 'fatal']),\n status: z.enum(['resolved', 'unresolved', 'ignored']),\n firstSeen: z.iso.datetime(),\n lastSeen: z.iso.datetime(),\n count: z.union([z.string().regex(/^\\d+$/), z.number().int().nonnegative()]),\n userCount: z.number().int().nonnegative(),\n project: z.object({ slug: z.string().min(1) }),\n }),\n);\n\nconst issueEventResponseSchema = z.array(\n z.object({\n id: z.string().optional(),\n eventID: z.string().optional(),\n dateCreated: z.iso.datetime(),\n message: z.string().nullable().optional(),\n platform: z.string().nullable().optional(),\n groupID: z.string().optional(),\n environment: z.string().nullable().optional(),\n }),\n);\n\nconst releaseResponseSchema = z.array(\n z.object({\n version: idString,\n dateCreated: z.iso.datetime(),\n dateReleased: z.iso.datetime().nullable(),\n lastEvent: z.iso.datetime().nullable(),\n projects: z.array(z.object({ slug: z.string().min(1) })),\n }),\n);\n\nconst errorStatsResponseSchema = z.object({\n intervals: z.array(z.iso.datetime()),\n groups: z.array(\n z.object({\n by: z.record(z.string(), z.union([z.string(), z.number()])),\n totals: z.record(z.string(), z.number()).optional(),\n series: z.record(z.string(), z.array(z.number())),\n }),\n ),\n start: z.string().optional(),\n end: z.string().optional(),\n});\n\nexport class SentryConnector extends BaseConnector<\n SentrySettings,\n SentryCredentials\n> {\n static readonly id = 'sentry';\n\n static readonly schemas = {\n issues: issueResponseSchema,\n issue_events: issueEventResponseSchema,\n releases: releaseResponseSchema,\n error_stats: errorStatsResponseSchema,\n } as const;\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":[]}
|
|
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/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.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\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\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 interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\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","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import {\n type HttpResponse,\n connectorUserAgent,\n parseEpoch,\n sanitizeAllowedUrl,\n standardRateLimitPolicy,\n} from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ChunkedSyncCursor,\n type ConnectorContext,\n type ConnectorDoc,\n type CredentialsSchema,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n defineConnectorDoc,\n defineResources,\n makeChunkedCursorGuard,\n paginateChunked,\n schemasFromResources,\n selectActivePhases,\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 const doc: ConnectorDoc = defineConnectorDoc({\n displayName: 'Sentry',\n category: 'engineering',\n brandColor: '#362D59',\n tagline:\n 'Sync issues, issue events, releases, and hourly error rates from a Sentry organization.',\n vendor: {\n name: 'Sentry',\n apiDocs: 'https://docs.sentry.io/api/',\n website: 'https://sentry.io',\n },\n auth: {\n summary:\n 'A Sentry auth token is required. Use an organization-level Internal Integration token or a User Auth Token with read access to issues, events, and releases.',\n setup: [\n 'Open Sentry → Settings → Custom Integrations → New Internal Integration (or Settings → Auth Tokens for a personal token).',\n 'Grant read access to Issues & Events and Releases.',\n 'Copy the generated token and store it as a secret, referencing it from the connector config as `authToken: secret(\"SENTRY_AUTH_TOKEN\")`.',\n 'Set the `organization` slug as it appears in your Sentry URL.',\n ],\n },\n rateLimit:\n 'Sentry returns X-Sentry-Rate-Limit-Remaining / X-Sentry-Rate-Limit-Reset headers (reset in seconds); list pagination uses the Link header (page size 100).',\n limitations: [\n 'Performance / trace data is out of scope (high cost, low signal for dashboards).',\n 'Self-hosted Sentry on custom hosts is out of scope (pagination URLs are pinned to sentry.io).',\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 sentryRateLimit = standardRateLimitPolicy({\n remainingHeader: 'x-sentry-rate-limit-remaining',\n resetHeader: 'x-sentry-rate-limit-reset',\n resetUnit: 's',\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\nconst isSentrySyncCursor = makeChunkedCursorGuard(PHASE_ORDER);\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 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\n// ---------------------------------------------------------------------------\n// Schemas — describe the per-resource API response shape consumed by request()\n// ---------------------------------------------------------------------------\n\nconst idString = z.string().min(1);\n\nconst issueResponseSchema = z.array(\n z.object({\n id: idString,\n shortId: z.string(),\n title: z.string(),\n level: z.enum(['debug', 'info', 'warning', 'error', 'fatal']),\n status: z.enum(['resolved', 'unresolved', 'ignored']),\n firstSeen: z.iso.datetime(),\n lastSeen: z.iso.datetime(),\n count: z.union([z.string().regex(/^\\d+$/), z.number().int().nonnegative()]),\n userCount: z.number().int().nonnegative(),\n project: z.object({ slug: z.string().min(1) }),\n }),\n);\n\nconst issueEventResponseSchema = z.array(\n z.object({\n id: z.string().optional(),\n eventID: z.string().optional(),\n dateCreated: z.iso.datetime(),\n message: z.string().nullable().optional(),\n platform: z.string().nullable().optional(),\n groupID: z.string().optional(),\n environment: z.string().nullable().optional(),\n }),\n);\n\nconst releaseResponseSchema = z.array(\n z.object({\n version: idString,\n dateCreated: z.iso.datetime(),\n dateReleased: z.iso.datetime().nullable(),\n lastEvent: z.iso.datetime().nullable(),\n projects: z.array(z.object({ slug: z.string().min(1) })),\n }),\n);\n\nconst errorStatsResponseSchema = z.object({\n intervals: z.array(z.iso.datetime()),\n groups: z.array(\n z.object({\n by: z.record(z.string(), z.union([z.string(), z.number()])),\n totals: z.record(z.string(), z.number()).optional(),\n series: z.record(z.string(), z.array(z.number())),\n }),\n ),\n start: z.string().optional(),\n end: z.string().optional(),\n});\n\nconst sentryResources = defineResources({\n sentry_issue: {\n shape: 'entity',\n description:\n 'Sentry issues (error groups) with level, status, occurrence count, affected user count, and first/last seen timestamps.',\n endpoint: 'GET /api/0/organizations/{organization}/issues/',\n responses: { issues: issueResponseSchema },\n },\n sentry_issue_event: {\n shape: 'event',\n description:\n 'Individual event occurrences sampled per issue, with platform, environment, level, and message.',\n endpoint: 'GET /api/0/issues/{issueId}/events/',\n notes:\n 'Events are sampled: at most eventsPerIssueCap recent events per issue per sync (Sentry caps a single events page at 100), so this is a representative sample, not a full audit trail.',\n responses: { issue_events: issueEventResponseSchema },\n },\n sentry_release: {\n shape: 'entity',\n description:\n 'Releases with their versions, associated project slugs, and creation/release/last-event timestamps.',\n endpoint: 'GET /api/0/organizations/{organization}/releases/',\n responses: { releases: releaseResponseSchema },\n },\n sentry_errors_per_hour: {\n shape: 'metric',\n description:\n 'Hourly count of error events, broken down by project, over the configured lookback window.',\n endpoint: 'GET /api/0/organizations/{organization}/stats_v2/',\n unit: 'errors',\n granularity: '1h',\n dimensions: [\n { name: 'project', description: 'Sentry project slug or id.' },\n ],\n responses: { error_stats: errorStatsResponseSchema },\n },\n});\n\nexport class SentryConnector extends BaseConnector<\n SentrySettings,\n SentryCredentials\n> {\n static readonly id = 'sentry';\n\n static readonly resources = sentryResources;\n\n static readonly schemas = schemasFromResources(sentryResources);\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': connectorUserAgent('sentry'),\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 activePhases(): SentryPhase[] {\n return selectActivePhases<SentryResource, SentryPhase>(\n (r) => {\n switch (r) {\n case 'issues':\n case 'issue_events':\n return 'issues';\n case 'releases':\n return 'releases';\n case 'errors_per_hour':\n return 'error_stats';\n }\n },\n PHASE_ORDER,\n this.settings.resources,\n );\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 const allowedPath = this.allowedPagePath(phase);\n if (allowedPath === null) {\n return null;\n }\n return sanitizeAllowedUrl({\n url: pageUrl,\n host: SENTRY_API_HOST,\n pathname: allowedPath,\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.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 options: SyncOptions,\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 releases = res.body;\n const cutoff = options.since ? new Date(options.since).getTime() : null;\n const filtered =\n cutoff !== null\n ? releases.filter((r) => {\n const ts = new Date(r.dateReleased ?? r.dateCreated).getTime();\n return Number.isFinite(ts) ? ts >= cutoff : true;\n })\n : releases;\n const lastRelease = releases.at(-1);\n const lastTs = lastRelease\n ? new Date(lastRelease.dateReleased ?? lastRelease.dateCreated).getTime()\n : null;\n const cutoffReached =\n cutoff !== null &&\n lastTs !== null &&\n Number.isFinite(lastTs) &&\n lastTs < cutoff;\n const next =\n !cutoffReached && nextLink && nextLink.hasResults\n ? this.sanitizePageUrl('releases', nextLink.url)\n : null;\n return { items: filtered, 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 = parseEpoch(issue.firstSeen, 'iso');\n const lastSeenMs = parseEpoch(issue.lastSeen, 'iso');\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 = parseEpoch(ev.dateCreated, 'iso');\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 = parseEpoch(r.dateCreated, 'iso');\n const releasedMs = parseEpoch(r.dateReleased, 'iso');\n const lastEventMs = parseEpoch(r.lastEvent, 'iso');\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 = parseEpoch(intervalIso, 'iso');\n const value = Number(rawValue);\n if (ts === null || !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 logger: this.logger,\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, options, 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, doc, SentryConnector } from './sentry';\nexport type { SentryResource, SentrySettings } from './sentry';\nexport default SentryConnector;\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rawdash/connector-sentry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Rawdash connector for Sentry — issues, issue events, releases, and error-rate metrics",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"zod": "^4.4.3",
|
|
26
|
-
"@rawdash/core": "0.
|
|
26
|
+
"@rawdash/core": "0.16.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"fast-check": "^4.8.0",
|
|
30
30
|
"tsup": "^8.0.0",
|
|
31
31
|
"typescript": "^5.7.2",
|
|
32
32
|
"vitest": "^4.1.4",
|
|
33
|
-
"@rawdash/connector-shared": "0.
|
|
34
|
-
"@rawdash/connector-test-utils": "0.0.
|
|
33
|
+
"@rawdash/connector-shared": "0.3.0",
|
|
34
|
+
"@rawdash/connector-test-utils": "0.0.3"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsup",
|