@rawdash/connector-sentry 0.14.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 CHANGED
@@ -1,73 +1,81 @@
1
- # @rawdash/connector-sentry
2
-
3
- Rawdash connector for [Sentry](https://sentry.io) — syncs issues, per-issue events, releases, and hourly error counts into the six-shape storage model.
4
-
5
- ## Auth setup
6
-
7
- The connector authenticates with a Sentry auth token. Two flavours work; pick whichever fits how you administer Sentry:
8
-
9
- - **Internal Integration token** (recommended for org-wide installs): Sentry → **Settings → Custom Integrations → New Internal Integration**. Give it `event:read`, `project:read`, and `org:read` scopes. The integration page surfaces a token starting with `sntrys_`.
10
- - **User Auth Token**: Sentry → **Settings → Account → API → Auth Tokens**. Tied to a specific user — fine for personal use, but rotate it when the user leaves.
1
+ <!-- This file is generated from connector metadata by scripts/generate-connector-docs.ts. Do not edit by hand. -->
11
2
 
12
- You also need your organization **slug** (the segment in your Sentry URL, e.g. `acme` in `https://sentry.io/organizations/acme/`).
3
+ # @rawdash/connector-sentry
13
4
 
14
- ## Configuration
5
+ [![npm version](https://img.shields.io/npm/v/@rawdash/connector-sentry)](https://www.npmjs.com/package/@rawdash/connector-sentry)
6
+ [![license](https://img.shields.io/npm/l/@rawdash/connector-sentry)](https://github.com/rawdash/rawdash/blob/main/LICENSE)
15
7
 
16
- ```ts
17
- import { SentryConnector } from '@rawdash/connector-sentry';
18
- import { secret } from '@rawdash/core';
19
-
20
- const sentry = new SentryConnector(
21
- {
22
- organization: 'acme',
23
- // projects: ['web', 'api'], // optional — restrict to specific project slugs or IDs
24
- // resources: ['issues', 'issue_events'], // optional — defaults to all four
25
- // eventsPerIssueCap: 100, // optional — max events sampled per issue (default 100)
26
- // statsLookbackHours: 24, // optional — hours of hourly stats refreshed per sync (default 24)
27
- },
28
- {
29
- authToken: secret('SENTRY_AUTH_TOKEN'),
30
- },
31
- );
32
- ```
8
+ Sync issues, issue events, releases, and hourly error rates from a Sentry organization.
33
9
 
34
- Or via `SentryConnector.create` (validates the input with the `configFields` Zod schema):
10
+ ## Install
35
11
 
36
- ```ts
37
- const sentry = SentryConnector.create({
38
- authToken: { $secret: 'SENTRY_AUTH_TOKEN' },
39
- organization: 'acme',
40
- // projects: ['web'],
41
- // resources: ['issues', 'errors_per_hour'],
42
- });
12
+ ```sh
13
+ npm install @rawdash/connector-sentry
43
14
  ```
44
15
 
45
- ### Choosing resources
16
+ ## Authentication
46
17
 
47
- The connector exposes four resources, written across three internal sync phases:
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.
48
19
 
49
- | Resource | Phase | What gets written |
50
- | ----------------- | ----------- | --------------------------------------------------------------------------------- |
51
- | `issues` | issues | `sentry_issue` entities, one per Sentry group |
52
- | `issue_events` | issues | `sentry_issue_event` events, sampled per issue (`eventsPerIssueCap`, default 100) |
53
- | `releases` | releases | `sentry_release` entities |
54
- | `errors_per_hour` | error_stats | `sentry_errors_per_hour` metric samples, hourly, per project |
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.
55
24
 
56
- `issue_events` shares the `issues` phase because each event is fetched against its parent issue. Enabling `issue_events` without `issues` still runs the issues query (so each issue's events can be located) but skips writing the issue entities themselves.
25
+ ## Configuration
57
26
 
58
- ### Example dashboard
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
59
52
 
60
53
  ```ts
61
- import { defineConfig, defineDashboard, defineMetric } from '@rawdash/core';
54
+ import {
55
+ defineConfig,
56
+ defineDashboard,
57
+ defineMetric,
58
+ secret,
59
+ } from '@rawdash/core';
60
+
61
+ const sentry = {
62
+ name: 'sentry',
63
+ connectorId: 'sentry',
64
+ config: {
65
+ authToken: secret('SENTRY_AUTH_TOKEN'),
66
+ organization: 'my-org',
67
+ projects: ['my-project'],
68
+ },
69
+ };
62
70
 
63
71
  export default defineConfig({
64
- connectors: [{ connector: sentry }],
72
+ connectors: [sentry],
65
73
  dashboards: {
66
- errors: defineDashboard({
74
+ engineering: defineDashboard({
67
75
  widgets: {
68
76
  unresolved_issues: {
69
77
  kind: 'stat',
70
- title: 'Unresolved issues',
78
+ title: 'Unresolved Issues',
71
79
  metric: defineMetric({
72
80
  connector: sentry,
73
81
  shape: 'entity',
@@ -76,91 +84,27 @@ export default defineConfig({
76
84
  filter: [{ field: 'status', op: 'eq', value: 'unresolved' }],
77
85
  }),
78
86
  },
79
- errors_per_hour: {
80
- kind: 'timeseries',
81
- title: 'Errors per hour',
82
- window: '24h',
83
- metric: defineMetric({
84
- connector: sentry,
85
- shape: 'metric',
86
- name: 'sentry_errors_per_hour',
87
- fn: 'sum',
88
- window: '24h',
89
- groupBy: { field: 'ts', granularity: 'hour' },
90
- }),
91
- },
92
- issues_by_level: {
93
- kind: 'distribution',
94
- title: 'Issues by level',
95
- metric: defineMetric({
96
- connector: sentry,
97
- shape: 'entity',
98
- entityType: 'sentry_issue',
99
- fn: 'count',
100
- groupBy: { field: 'level' },
101
- }),
102
- },
103
87
  },
104
88
  }),
105
89
  },
106
90
  });
107
91
  ```
108
92
 
109
- ## Data model
110
-
111
- | Storage shape | Entity/event/metric type | Key attributes |
112
- | ------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- |
113
- | entity | `sentry_issue` | shortId, title, level, status, firstSeen, lastSeen, count, userCount, projectSlug |
114
- | event | `sentry_issue_event` | eventId, issueId, issueShortId, projectSlug, level, platform, environment, message |
115
- | entity | `sentry_release` | version, projects, dateCreated, dateReleased, lastEvent |
116
- | metric | `sentry_errors_per_hour` | value = error count for the hour; attributes = `{ project }`; one sample per (project, hour-aligned ts) |
117
-
118
- Timestamps are stored as Unix epoch milliseconds. `sentry_issue_event` rows are sampled — the connector fetches at most `eventsPerIssueCap` recent events per issue per sync (Sentry caps a single `/events` page at 100), so the events shape is a representative sample, not a full audit trail.
93
+ ## Rate limits
119
94
 
120
- ## Sync behaviour
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).
121
96
 
122
- - **Backfill** (`mode: 'full'`): paginates `/api/0/organizations/{org}/issues/` and `/releases/` via Sentry's Link header (page size 100), respecting `results="true"` to stop cleanly. Issue and release entity scopes (plus the `sentry_issue_event` event scope) are cleared at the start of their phase so deletions in Sentry converge.
123
- - **Incremental** (`mode: 'latest'`): applies `query=lastSeen:>{since}` to the issues endpoint so only issues with new occurrences are pulled. Releases and stats are still refreshed on every sync, since both are small and benefit from a fresh snapshot.
124
- - **Rate limits**: Sentry sends `X-Sentry-Rate-Limit-Remaining` and `X-Sentry-Rate-Limit-Reset` on every response — the connector reports the parsed state back to the host via the shared `sentryRateLimit` policy so the engine can budget future requests. 429 responses are surfaced as `RateLimitError` by the shared HTTP client.
125
- - **Resumable**: every paginated phase yields a `(phase, pageUrl)` cursor. Pagination URLs are sanitized on the way in — only `https://sentry.io/api/0/...` is accepted — to prevent a malicious or corrupted cursor from steering a follow-up request elsewhere.
97
+ ## Limitations
126
98
 
127
- ## Errors
128
-
129
- `@rawdash/connector-shared` maps Sentry's HTTP responses to typed errors automatically:
130
-
131
- - `401` / `403` → `AuthError` — host stops syncing until the token is replaced.
132
- - `429` → `RateLimitError` — host backs off and reschedules.
133
- - `5xx` → `TransientError` — host retries on the next tick.
134
-
135
- ## Out of scope (post-MVP)
136
-
137
- - Performance / Trace data — high cost, low signal for the launch dashboard set.
138
- - Per-event payloads (stack traces, breadcrumbs) — the connector counts events but does not store their payloads.
139
- - Self-hosted Sentry instances on custom hosts — pagination URLs are pinned to `sentry.io`.
140
-
141
- ## Registering in the MCP server
142
-
143
- ```ts
144
- import { SentryConnector, configFields } from '@rawdash/connector-sentry';
145
-
146
- createMcpServer({
147
- // ...
148
- connectorFactories: [
149
- {
150
- id: 'sentry',
151
- configFields,
152
- create: SentryConnector.create,
153
- },
154
- ],
155
- });
156
- ```
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).
157
101
 
158
- ## Property tests
102
+ ## Links
159
103
 
160
- Resources in this connector have fast-check property tests under `src/property.test.ts` that:
104
+ - [Rawdash docs](https://rawdash.dev/docs/connectors/)
105
+ - [Sentry API docs](https://docs.sentry.io/api/)
106
+ - [GitHub](https://github.com/rawdash/rawdash)
161
107
 
162
- 1. Generate synthetic API payloads from a Zod schema mirroring Sentry's response shape.
163
- 2. Pipe them through `connector.sync()` against an `InMemoryStorage` instance.
164
- 3. Assert universal invariants — non-empty entity ids, finite event timestamps, no `undefined` reaching storage, no thrown errors on any valid input — plus per-resource counts.
108
+ ## License
165
109
 
166
- The helper lives in `@rawdash/connector-test-utils`. When adding a new resource, add a Zod schema for its payload and a test wired up via `runPropertySyncTest`.
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,152 @@ 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
+ };
126
+ static readonly schemas: {
127
+ readonly issues: z.ZodArray<z.ZodObject<{
128
+ id: z.ZodString;
129
+ shortId: z.ZodString;
130
+ title: z.ZodString;
131
+ level: z.ZodEnum<{
132
+ error: "error";
133
+ debug: "debug";
134
+ info: "info";
135
+ warning: "warning";
136
+ fatal: "fatal";
137
+ }>;
138
+ status: z.ZodEnum<{
139
+ resolved: "resolved";
140
+ unresolved: "unresolved";
141
+ ignored: "ignored";
142
+ }>;
143
+ firstSeen: z.ZodISODateTime;
144
+ lastSeen: z.ZodISODateTime;
145
+ count: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
146
+ userCount: z.ZodNumber;
147
+ project: z.ZodObject<{
148
+ slug: z.ZodString;
149
+ }, z.core.$strip>;
150
+ }, z.core.$strip>>;
151
+ } & {
152
+ readonly issue_events: z.ZodArray<z.ZodObject<{
153
+ id: z.ZodOptional<z.ZodString>;
154
+ eventID: z.ZodOptional<z.ZodString>;
155
+ dateCreated: z.ZodISODateTime;
156
+ message: z.ZodOptional<z.ZodNullable<z.ZodString>>;
157
+ platform: z.ZodOptional<z.ZodNullable<z.ZodString>>;
158
+ groupID: z.ZodOptional<z.ZodString>;
159
+ environment: z.ZodOptional<z.ZodNullable<z.ZodString>>;
160
+ }, z.core.$strip>>;
161
+ } & {
162
+ readonly releases: z.ZodArray<z.ZodObject<{
163
+ version: z.ZodString;
164
+ dateCreated: z.ZodISODateTime;
165
+ dateReleased: z.ZodNullable<z.ZodISODateTime>;
166
+ lastEvent: z.ZodNullable<z.ZodISODateTime>;
167
+ projects: z.ZodArray<z.ZodObject<{
168
+ slug: z.ZodString;
169
+ }, z.core.$strip>>;
170
+ }, z.core.$strip>>;
171
+ } & {
172
+ readonly error_stats: z.ZodObject<{
173
+ intervals: z.ZodArray<z.ZodISODateTime>;
174
+ groups: z.ZodArray<z.ZodObject<{
175
+ by: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
176
+ totals: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
177
+ series: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodNumber>>;
178
+ }, z.core.$strip>>;
179
+ start: z.ZodOptional<z.ZodString>;
180
+ end: z.ZodOptional<z.ZodString>;
181
+ }, z.core.$strip>;
182
+ } & Readonly<Record<string, z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>>;
36
183
  static create(input: unknown, ctx?: ConnectorContext): SentryConnector;
37
184
  readonly id = "sentry";
38
185
  readonly credentials: {
@@ -43,7 +190,6 @@ declare class SentryConnector extends BaseConnector<SentrySettings, SentryCreden
43
190
  };
44
191
  private buildHeaders;
45
192
  private fetch;
46
- private isResourceEnabled;
47
193
  private activePhases;
48
194
  private allowedPagePath;
49
195
  private sanitizePageUrl;
@@ -61,4 +207,4 @@ declare class SentryConnector extends BaseConnector<SentrySettings, SentryCreden
61
207
  sync(options: SyncOptions, storage: StorageHandle, signal?: AbortSignal): Promise<SyncResult>;
62
208
  }
63
209
 
64
- 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
- var sentryRateLimit = {
5
- parse(h) {
6
- const concurrent = h.get("x-sentry-rate-limit-remaining");
7
- const reset = h.get("x-sentry-rate-limit-reset");
8
- if (concurrent === null || reset === null) {
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
- const remaining = Number(concurrent);
12
- const resetSec = Number(reset);
13
- if (!Number.isFinite(remaining) || !Number.isFinite(resetSec) || resetSec < 0) {
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
- return { remaining, resetAt: new Date(resetSec * 1e3) };
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
- paginateChunked
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 \u2014 enabling it without 'issues' still runs the issues query, but skips writing issue entities."
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
- function isSentrySyncCursor(value) {
68
- if (typeof value !== "object" || value === null) {
69
- return false;
70
- }
71
- const v = value;
72
- if (typeof v.phase !== "string") {
73
- return false;
74
- }
75
- if (!PHASE_ORDER.includes(v.phase)) {
76
- return false;
77
- }
78
- if (v.page !== null && typeof v.page !== "string") {
79
- return false;
80
- }
81
- return true;
82
- }
83
- function safeTimestamp(iso) {
84
- if (iso === null || iso === void 0) {
85
- return null;
86
- }
87
- const ms = new Date(iso).getTime();
88
- return Number.isFinite(ms) ? ms : null;
89
- }
164
+ var isSentrySyncCursor = makeChunkedCursorGuard(PHASE_ORDER);
90
165
  function parseSentryLink(header, rel) {
91
166
  if (!header) {
92
167
  return null;
@@ -114,8 +189,89 @@ var DEFAULT_EVENTS_PER_ISSUE = 100;
114
189
  var DEFAULT_STATS_LOOKBACK_HOURS = 24;
115
190
  var ISSUES_PAGE_SIZE = 100;
116
191
  var RELEASES_PAGE_SIZE = 100;
192
+ var idString = z.string().min(1);
193
+ var issueResponseSchema = z.array(
194
+ z.object({
195
+ id: idString,
196
+ shortId: z.string(),
197
+ title: z.string(),
198
+ level: z.enum(["debug", "info", "warning", "error", "fatal"]),
199
+ status: z.enum(["resolved", "unresolved", "ignored"]),
200
+ firstSeen: z.iso.datetime(),
201
+ lastSeen: z.iso.datetime(),
202
+ count: z.union([z.string().regex(/^\d+$/), z.number().int().nonnegative()]),
203
+ userCount: z.number().int().nonnegative(),
204
+ project: z.object({ slug: z.string().min(1) })
205
+ })
206
+ );
207
+ var issueEventResponseSchema = z.array(
208
+ z.object({
209
+ id: z.string().optional(),
210
+ eventID: z.string().optional(),
211
+ dateCreated: z.iso.datetime(),
212
+ message: z.string().nullable().optional(),
213
+ platform: z.string().nullable().optional(),
214
+ groupID: z.string().optional(),
215
+ environment: z.string().nullable().optional()
216
+ })
217
+ );
218
+ var releaseResponseSchema = z.array(
219
+ z.object({
220
+ version: idString,
221
+ dateCreated: z.iso.datetime(),
222
+ dateReleased: z.iso.datetime().nullable(),
223
+ lastEvent: z.iso.datetime().nullable(),
224
+ projects: z.array(z.object({ slug: z.string().min(1) }))
225
+ })
226
+ );
227
+ var errorStatsResponseSchema = z.object({
228
+ intervals: z.array(z.iso.datetime()),
229
+ groups: z.array(
230
+ z.object({
231
+ by: z.record(z.string(), z.union([z.string(), z.number()])),
232
+ totals: z.record(z.string(), z.number()).optional(),
233
+ series: z.record(z.string(), z.array(z.number()))
234
+ })
235
+ ),
236
+ start: z.string().optional(),
237
+ end: z.string().optional()
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
+ });
117
271
  var SentryConnector = class _SentryConnector extends BaseConnector {
118
272
  static id = "sentry";
273
+ static resources = sentryResources;
274
+ static schemas = schemasFromResources(sentryResources);
119
275
  static create(input, ctx) {
120
276
  const parsed = configFields.parse(input);
121
277
  return new _SentryConnector(
@@ -135,7 +291,7 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
135
291
  buildHeaders() {
136
292
  return {
137
293
  Authorization: `Bearer ${this.creds.authToken}`,
138
- "User-Agent": "rawdash/connector-sentry (+https://rawdash.dev)"
294
+ "User-Agent": connectorUserAgent("sentry")
139
295
  };
140
296
  }
141
297
  fetch(url, resource, signal) {
@@ -149,25 +305,22 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
149
305
  // -------------------------------------------------------------------------
150
306
  // Resource enablement
151
307
  // -------------------------------------------------------------------------
152
- isResourceEnabled(resource) {
153
- const enabled = this.settings.resources;
154
- if (!enabled || enabled.length === 0) {
155
- return true;
156
- }
157
- return enabled.includes(resource);
158
- }
159
308
  activePhases() {
160
- const phases = [];
161
- if (this.isResourceEnabled("issues") || this.isResourceEnabled("issue_events")) {
162
- phases.push("issues");
163
- }
164
- if (this.isResourceEnabled("releases")) {
165
- phases.push("releases");
166
- }
167
- if (this.isResourceEnabled("errors_per_hour")) {
168
- phases.push("error_stats");
169
- }
170
- return phases;
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
+ );
171
324
  }
172
325
  // -------------------------------------------------------------------------
173
326
  // URL building + sanitization
@@ -184,22 +337,15 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
184
337
  }
185
338
  }
186
339
  sanitizePageUrl(phase, pageUrl) {
187
- if (pageUrl === null) {
188
- return null;
189
- }
190
340
  const allowedPath = this.allowedPagePath(phase);
191
341
  if (allowedPath === null) {
192
342
  return null;
193
343
  }
194
- try {
195
- const u = new URL(pageUrl);
196
- if (u.protocol !== "https:" || u.host !== SENTRY_API_HOST || u.pathname !== allowedPath) {
197
- return null;
198
- }
199
- return u.toString();
200
- } catch {
201
- return null;
202
- }
344
+ return sanitizeAllowedUrl({
345
+ url: pageUrl,
346
+ host: SENTRY_API_HOST,
347
+ pathname: allowedPath
348
+ });
203
349
  }
204
350
  resolveCursor(cursor) {
205
351
  if (!isSentrySyncCursor(cursor)) {
@@ -219,7 +365,7 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
219
365
  for (const project of this.settings.projects ?? []) {
220
366
  u.searchParams.append("project", project);
221
367
  }
222
- if (options.mode === "latest" && options.since) {
368
+ if (options.since) {
223
369
  u.searchParams.set("query", `lastSeen:>${options.since}`);
224
370
  }
225
371
  return u.toString();
@@ -280,12 +426,21 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
280
426
  }
281
427
  return { items: [{ issues: res.body, eventsByIssue }], next };
282
428
  }
283
- async fetchReleasesPage(page, signal) {
429
+ async fetchReleasesPage(page, options, signal) {
284
430
  const url = page ?? this.buildInitialReleasesUrl();
285
431
  const res = await this.fetch(url, "releases", signal);
286
432
  const nextLink = parseSentryLink(res.headers.get("link"), "next");
287
- const next = nextLink && nextLink.hasResults ? this.sanitizePageUrl("releases", nextLink.url) : null;
288
- return { items: res.body, next };
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 };
289
444
  }
290
445
  async fetchErrorStats(signal) {
291
446
  const res = await this.fetch(
@@ -304,8 +459,8 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
304
459
  for (const issue of item.issues) {
305
460
  if (writeEntities) {
306
461
  const count = typeof issue.count === "string" ? Number(issue.count) : issue.count;
307
- const firstSeenMs = safeTimestamp(issue.firstSeen);
308
- const lastSeenMs = safeTimestamp(issue.lastSeen);
462
+ const firstSeenMs = parseEpoch(issue.firstSeen, "iso");
463
+ const lastSeenMs = parseEpoch(issue.lastSeen, "iso");
309
464
  if (firstSeenMs === null || lastSeenMs === null) {
310
465
  console.warn(
311
466
  `[connector-sentry] skipping issue ${issue.id} with unparseable firstSeen/lastSeen`
@@ -336,7 +491,7 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
336
491
  if (eventId === null) {
337
492
  continue;
338
493
  }
339
- const startTs = safeTimestamp(ev.dateCreated);
494
+ const startTs = parseEpoch(ev.dateCreated, "iso");
340
495
  if (startTs === null) {
341
496
  continue;
342
497
  }
@@ -361,9 +516,9 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
361
516
  }
362
517
  async writeReleases(storage, releases) {
363
518
  for (const r of releases) {
364
- const createdMs = safeTimestamp(r.dateCreated);
365
- const releasedMs = safeTimestamp(r.dateReleased);
366
- const lastEventMs = safeTimestamp(r.lastEvent);
519
+ const createdMs = parseEpoch(r.dateCreated, "iso");
520
+ const releasedMs = parseEpoch(r.dateReleased, "iso");
521
+ const lastEventMs = parseEpoch(r.lastEvent, "iso");
367
522
  if (createdMs === null) {
368
523
  console.warn(
369
524
  `[connector-sentry] skipping release ${r.version} with unparseable dateCreated`
@@ -396,9 +551,9 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
396
551
  if (intervalIso === void 0 || rawValue === void 0) {
397
552
  continue;
398
553
  }
399
- const ts = new Date(intervalIso).getTime();
554
+ const ts = parseEpoch(intervalIso, "iso");
400
555
  const value = Number(rawValue);
401
- if (!Number.isFinite(ts) || !Number.isFinite(value)) {
556
+ if (ts === null || !Number.isFinite(value)) {
402
557
  continue;
403
558
  }
404
559
  samples.push({
@@ -422,12 +577,13 @@ var SentryConnector = class _SentryConnector extends BaseConnector {
422
577
  phases,
423
578
  cursor,
424
579
  signal,
580
+ logger: this.logger,
425
581
  fetchPage: async (phase, page, sig) => {
426
582
  switch (phase) {
427
583
  case "issues":
428
584
  return this.fetchIssuesPage(page, options, sig);
429
585
  case "releases":
430
- return this.fetchReleasesPage(page, sig);
586
+ return this.fetchReleasesPage(page, options, sig);
431
587
  case "error_stats":
432
588
  return this.fetchErrorStats(sig);
433
589
  }
@@ -474,6 +630,7 @@ var index_default = SentryConnector;
474
630
  export {
475
631
  SentryConnector,
476
632
  configFields,
477
- index_default as default
633
+ index_default as default,
634
+ doc
478
635
  };
479
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\nexport class SentryConnector extends BaseConnector<\n SentrySettings,\n SentryCredentials\n> {\n static readonly id = 'sentry';\n\n static create(input: unknown, ctx?: ConnectorContext): SentryConnector {\n const parsed = configFields.parse(input);\n return new SentryConnector(\n {\n organization: parsed.organization,\n projects: parsed.projects,\n resources: parsed.resources,\n eventsPerIssueCap: parsed.eventsPerIssueCap,\n statsLookbackHours: parsed.statsLookbackHours,\n },\n { authToken: parsed.authToken },\n ctx,\n );\n }\n\n readonly id = 'sentry';\n override readonly credentials = sentryCredentials;\n\n private buildHeaders(): Record<string, string> {\n return {\n Authorization: `Bearer ${this.creds.authToken}`,\n 'User-Agent': 'rawdash/connector-sentry (+https://rawdash.dev)',\n };\n }\n\n private fetch<T>(\n url: string,\n resource: string,\n signal?: AbortSignal,\n ): Promise<HttpResponse<T>> {\n return this.get<T>(url, {\n resource,\n headers: this.buildHeaders(),\n signal,\n rateLimit: sentryRateLimit,\n });\n }\n\n // -------------------------------------------------------------------------\n // Resource enablement\n // -------------------------------------------------------------------------\n\n private isResourceEnabled(resource: SentryResource): boolean {\n const enabled = this.settings.resources;\n if (!enabled || enabled.length === 0) {\n return true;\n }\n return enabled.includes(resource);\n }\n\n private activePhases(): SentryPhase[] {\n const phases: SentryPhase[] = [];\n if (\n this.isResourceEnabled('issues') ||\n this.isResourceEnabled('issue_events')\n ) {\n phases.push('issues');\n }\n if (this.isResourceEnabled('releases')) {\n phases.push('releases');\n }\n if (this.isResourceEnabled('errors_per_hour')) {\n phases.push('error_stats');\n }\n return phases;\n }\n\n // -------------------------------------------------------------------------\n // URL building + sanitization\n // -------------------------------------------------------------------------\n\n private allowedPagePath(phase: SentryPhase): string | null {\n const org = this.settings.organization;\n switch (phase) {\n case 'issues':\n return `/api/0/organizations/${org}/issues/`;\n case 'releases':\n return `/api/0/organizations/${org}/releases/`;\n case 'error_stats':\n return null;\n }\n }\n\n private sanitizePageUrl(\n phase: SentryPhase,\n pageUrl: string | null,\n ): string | null {\n if (pageUrl === null) {\n return null;\n }\n const allowedPath = this.allowedPagePath(phase);\n if (allowedPath === null) {\n return null;\n }\n try {\n const u = new URL(pageUrl);\n if (\n u.protocol !== 'https:' ||\n u.host !== SENTRY_API_HOST ||\n u.pathname !== allowedPath\n ) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n }\n\n private resolveCursor(cursor: unknown): SentrySyncCursor | undefined {\n if (!isSentrySyncCursor(cursor)) {\n return undefined;\n }\n return {\n phase: cursor.phase,\n page: this.sanitizePageUrl(cursor.phase, cursor.page),\n };\n }\n\n private buildInitialIssuesUrl(options: SyncOptions): string {\n const u = new URL(\n `${SENTRY_API_BASE}/organizations/${this.settings.organization}/issues/`,\n );\n u.searchParams.set('limit', String(ISSUES_PAGE_SIZE));\n u.searchParams.set('sort', 'date');\n for (const project of this.settings.projects ?? []) {\n u.searchParams.append('project', project);\n }\n if (options.mode === 'latest' && options.since) {\n u.searchParams.set('query', `lastSeen:>${options.since}`);\n }\n return u.toString();\n }\n\n private buildInitialReleasesUrl(): string {\n const u = new URL(\n `${SENTRY_API_BASE}/organizations/${this.settings.organization}/releases/`,\n );\n u.searchParams.set('per_page', String(RELEASES_PAGE_SIZE));\n for (const project of this.settings.projects ?? []) {\n u.searchParams.append('project', project);\n }\n return u.toString();\n }\n\n private buildStatsUrl(): string {\n const lookback =\n this.settings.statsLookbackHours ?? DEFAULT_STATS_LOOKBACK_HOURS;\n const u = new URL(\n `${SENTRY_API_BASE}/organizations/${this.settings.organization}/stats_v2/`,\n );\n u.searchParams.set('field', 'sum(quantity)');\n u.searchParams.set('category', 'error');\n u.searchParams.set('interval', '1h');\n u.searchParams.set('statsPeriod', `${lookback}h`);\n u.searchParams.append('groupBy', 'project');\n for (const project of this.settings.projects ?? []) {\n u.searchParams.append('project', project);\n }\n return u.toString();\n }\n\n private buildIssueEventsUrl(issueId: string): string {\n const cap = this.settings.eventsPerIssueCap ?? DEFAULT_EVENTS_PER_ISSUE;\n const u = new URL(`${SENTRY_API_BASE}/issues/${issueId}/events/`);\n u.searchParams.set(\n 'limit',\n String(Math.min(cap, DEFAULT_EVENTS_PER_ISSUE)),\n );\n return u.toString();\n }\n\n // -------------------------------------------------------------------------\n // Fetchers\n // -------------------------------------------------------------------------\n\n private async fetchIssuesPage(\n page: string | null,\n options: SyncOptions,\n signal: AbortSignal | undefined,\n ): Promise<{ items: IssuesPageItem[]; next: string | null }> {\n const url = page ?? this.buildInitialIssuesUrl(options);\n const res = await this.fetch<SentryIssue[]>(url, 'issues', signal);\n\n const nextLink = parseSentryLink(res.headers.get('link'), 'next');\n const next =\n nextLink && nextLink.hasResults\n ? this.sanitizePageUrl('issues', nextLink.url)\n : null;\n\n const eventsByIssue = new Map<string, SentryIssueEvent[]>();\n if (this.isResourceEnabled('issue_events')) {\n for (const issue of res.body) {\n signal?.throwIfAborted();\n const eventsRes = await this.fetch<SentryIssueEvent[]>(\n this.buildIssueEventsUrl(issue.id),\n 'issue_events',\n signal,\n );\n eventsByIssue.set(issue.id, eventsRes.body);\n }\n }\n\n return { items: [{ issues: res.body, eventsByIssue }], next };\n }\n\n private async fetchReleasesPage(\n page: string | null,\n signal: AbortSignal | undefined,\n ): Promise<{ items: SentryRelease[]; next: string | null }> {\n const url = page ?? this.buildInitialReleasesUrl();\n const res = await this.fetch<SentryRelease[]>(url, 'releases', signal);\n const nextLink = parseSentryLink(res.headers.get('link'), 'next');\n const next =\n nextLink && nextLink.hasResults\n ? this.sanitizePageUrl('releases', nextLink.url)\n : null;\n return { items: res.body, next };\n }\n\n private async fetchErrorStats(\n signal: AbortSignal | undefined,\n ): Promise<{ items: SentryStatsResponse[]; next: string | null }> {\n const res = await this.fetch<SentryStatsResponse>(\n this.buildStatsUrl(),\n 'error_stats',\n signal,\n );\n return { items: [res.body], next: null };\n }\n\n // -------------------------------------------------------------------------\n // Writers\n // -------------------------------------------------------------------------\n\n private async writeIssuesPage(\n storage: StorageHandle,\n item: IssuesPageItem,\n ): Promise<void> {\n const writeEntities = this.isResourceEnabled('issues');\n const writeEvents = this.isResourceEnabled('issue_events');\n\n for (const issue of item.issues) {\n if (writeEntities) {\n const count =\n typeof issue.count === 'string' ? Number(issue.count) : issue.count;\n const firstSeenMs = safeTimestamp(issue.firstSeen);\n const lastSeenMs = safeTimestamp(issue.lastSeen);\n if (firstSeenMs === null || lastSeenMs === null) {\n console.warn(\n `[connector-sentry] skipping issue ${issue.id} with unparseable firstSeen/lastSeen`,\n );\n } else {\n await storage.entity({\n type: 'sentry_issue',\n id: issue.id,\n attributes: {\n shortId: issue.shortId,\n title: issue.title,\n level: issue.level,\n status: issue.status,\n firstSeen: firstSeenMs,\n lastSeen: lastSeenMs,\n count: Number.isFinite(count) ? count : 0,\n userCount: issue.userCount,\n projectSlug: issue.project.slug,\n },\n updated_at: lastSeenMs,\n });\n }\n }\n\n if (writeEvents) {\n const events = item.eventsByIssue.get(issue.id) ?? [];\n for (const ev of events) {\n const eventId = ev.eventID ?? ev.id ?? null;\n if (eventId === null) {\n continue;\n }\n const startTs = safeTimestamp(ev.dateCreated);\n if (startTs === null) {\n continue;\n }\n await storage.event({\n name: 'sentry_issue_event',\n start_ts: startTs,\n end_ts: null,\n attributes: {\n eventId,\n issueId: issue.id,\n issueShortId: issue.shortId,\n projectSlug: issue.project.slug,\n level: issue.level,\n platform: ev.platform ?? null,\n environment: ev.environment ?? null,\n message: ev.message ?? null,\n },\n });\n }\n }\n }\n }\n\n private async writeReleases(\n storage: StorageHandle,\n releases: SentryRelease[],\n ): Promise<void> {\n for (const r of releases) {\n const createdMs = safeTimestamp(r.dateCreated);\n const releasedMs = safeTimestamp(r.dateReleased);\n const lastEventMs = safeTimestamp(r.lastEvent);\n if (createdMs === null) {\n console.warn(\n `[connector-sentry] skipping release ${r.version} with unparseable dateCreated`,\n );\n continue;\n }\n await storage.entity({\n type: 'sentry_release',\n id: r.version,\n attributes: {\n version: r.version,\n projects: r.projects.map((p) => p.slug),\n dateCreated: createdMs,\n dateReleased: releasedMs,\n lastEvent: lastEventMs,\n },\n updated_at: Math.max(createdMs, releasedMs ?? 0, lastEventMs ?? 0),\n });\n }\n }\n\n private async writeErrorStats(\n storage: StorageHandle,\n stats: SentryStatsResponse,\n ): Promise<void> {\n const samples: Array<{\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n }> = [];\n for (const group of stats.groups) {\n const project = group.by['project'];\n const projectKey = project !== undefined ? String(project) : 'unknown';\n const series = group.series['sum(quantity)'] ?? [];\n for (let i = 0; i < stats.intervals.length; i++) {\n const intervalIso = stats.intervals[i];\n const rawValue = series[i];\n if (intervalIso === undefined || rawValue === undefined) {\n continue;\n }\n const ts = new Date(intervalIso).getTime();\n const value = Number(rawValue);\n if (!Number.isFinite(ts) || !Number.isFinite(value)) {\n continue;\n }\n samples.push({\n name: 'sentry_errors_per_hour',\n ts,\n value,\n attributes: { project: projectKey },\n });\n }\n }\n await storage.metrics(samples, { names: ['sentry_errors_per_hour'] });\n }\n\n // -------------------------------------------------------------------------\n // sync\n // -------------------------------------------------------------------------\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const cursor = this.resolveCursor(options.cursor);\n const isFull = options.mode === 'full';\n const phases = this.activePhases();\n\n return paginateChunked<SentryPhase, string>({\n phases,\n cursor,\n signal,\n fetchPage: async (phase, page, sig) => {\n switch (phase) {\n case 'issues':\n return this.fetchIssuesPage(page, options, sig);\n case 'releases':\n return this.fetchReleasesPage(page, sig);\n case 'error_stats':\n return this.fetchErrorStats(sig);\n }\n },\n writeBatch: async (phase, items, page) => {\n if (isFull && page === null) {\n switch (phase) {\n case 'issues':\n if (this.isResourceEnabled('issues')) {\n await storage.entities([], { types: ['sentry_issue'] });\n }\n if (this.isResourceEnabled('issue_events')) {\n await storage.events([], { names: ['sentry_issue_event'] });\n }\n break;\n case 'releases':\n await storage.entities([], { types: ['sentry_release'] });\n break;\n case 'error_stats':\n break;\n }\n }\n switch (phase) {\n case 'issues':\n for (const item of items as IssuesPageItem[]) {\n await this.writeIssuesPage(storage, item);\n }\n return;\n case 'releases':\n return this.writeReleases(storage, items as SentryRelease[]);\n case 'error_stats':\n for (const stats of items as SentryStatsResponse[]) {\n await this.writeErrorStats(storage, stats);\n }\n return;\n }\n },\n });\n }\n}\n","import { SentryConnector } from './sentry';\n\nexport { configFields, SentryConnector } from './sentry';\nexport type { SentryResource, SentrySettings } from './sentry';\nexport default SentryConnector;\n"],"mappings":";AEAO,IAAM,sBAAsB;AAE5B,IAAM,qBAAqB,qBAAqB,mBAAmB;AEuBnE,IAAM,kBAAmC;EAC9C,MAAM,GAAG;AACP,UAAM,aAAa,EAAE,IAAI,+BAA+B;AACxD,UAAM,QAAQ,EAAE,IAAI,2BAA2B;AAC/C,QAAI,eAAe,QAAQ,UAAU,MAAM;AACzC,aAAO;IACT;AACA,UAAM,YAAY,OAAO,UAAU;AACnC,UAAM,WAAW,OAAO,KAAK;AAC7B,QACE,CAAC,OAAO,SAAS,SAAS,KAC1B,CAAC,OAAO,SAAS,QAAQ,KACzB,WAAW,GACX;AACA,aAAO;IACT;AACA,WAAO,EAAE,WAAW,SAAS,IAAI,KAAK,WAAW,GAAI,EAAE;EACzD;AACF;;;AE1CA;AAAA,EACE;AAAA,EAOA;AAAA,EACA;AAAA,OACK;AACP,SAAS,SAAS;AAMX,IAAM,eAAe;AAAA,EAC1B,EAAE,OAAO;AAAA,IACP,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK;AAAA,MAChD,OAAO;AAAA,MACP,aACE;AAAA,MACF,aAAa;AAAA,MACb,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,cAAc,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,KAAK;AAAA,MACnC,OAAO;AAAA,MACP,aAAa;AAAA,MACb,aAAa;AAAA,IACf,CAAC;AAAA,IACD,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK;AAAA,MAC9D,OAAO;AAAA,MACP,aACE;AAAA,IACJ,CAAC;AAAA,IACD,WAAW,EACR,MAAM,EAAE,KAAK,CAAC,UAAU,gBAAgB,YAAY,iBAAiB,CAAC,CAAC,EACvE,SAAS,EACT,SAAS,EACT,KAAK;AAAA,MACJ,OAAO;AAAA,MACP,aACE;AAAA,IACJ,CAAC;AAAA,IACH,mBAAmB,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,GAAG,EAAE,SAAS,EAAE,KAAK;AAAA,MACtE,OAAO;AAAA,MACP,aACE;AAAA,MACF,aAAa;AAAA,IACf,CAAC;AAAA,IACD,oBAAoB,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,GAAG,EAAE,SAAS,EAAE,KAAK;AAAA,MACvE,OAAO;AAAA,MACP,aACE;AAAA,MACF,aAAa;AAAA,IACf,CAAC;AAAA,EACH,CAAC;AACH;AAgBA,IAAM,oBAAoB;AAAA,EACxB,WAAW;AAAA,IACT,aAAa;AAAA,IACb,MAAM;AAAA,EACR;AACF;AAQA,IAAM,cAAc,CAAC,UAAU,YAAY,aAAa;AAMxD,SAAS,mBAAmB,OAA2C;AACrE,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,WAAO;AAAA,EACT;AACA,QAAM,IAAI;AACV,MAAI,OAAO,EAAE,UAAU,UAAU;AAC/B,WAAO;AAAA,EACT;AACA,MAAI,CAAE,YAAkC,SAAS,EAAE,KAAK,GAAG;AACzD,WAAO;AAAA,EACT;AACA,MAAI,EAAE,SAAS,QAAQ,OAAO,EAAE,SAAS,UAAU;AACjD,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAuEA,SAAS,cAAc,KAA+C;AACpE,MAAI,QAAQ,QAAQ,QAAQ,QAAW;AACrC,WAAO;AAAA,EACT;AACA,QAAM,KAAK,IAAI,KAAK,GAAG,EAAE,QAAQ;AACjC,SAAO,OAAO,SAAS,EAAE,IAAI,KAAK;AACpC;AAEA,SAAS,gBACP,QACA,KACmB;AACnB,MAAI,CAAC,QAAQ;AACX,WAAO;AAAA,EACT;AACA,aAAW,QAAQ,OAAO,MAAM,GAAG,GAAG;AACpC,UAAM,IAAI,KAAK,MAAM,uBAAuB;AAC5C,QAAI,CAAC,GAAG;AACN;AAAA,IACF;AACA,UAAM,MAAM,EAAE,CAAC;AACf,UAAM,QAAQ,EAAE,CAAC;AACjB,UAAM,WAAW,MAAM,MAAM,eAAe;AAC5C,QAAI,CAAC,YAAY,SAAS,CAAC,MAAM,KAAK;AACpC;AAAA,IACF;AACA,UAAM,eAAe,MAAM,MAAM,mBAAmB;AACpD,UAAM,aAAa,eAAe,aAAa,CAAC,MAAM,SAAS;AAC/D,WAAO,EAAE,KAAK,WAAW;AAAA,EAC3B;AACA,SAAO;AACT;AAMA,IAAM,kBAAkB;AACxB,IAAM,kBAAkB,WAAW,eAAe;AAClD,IAAM,2BAA2B;AACjC,IAAM,+BAA+B;AACrC,IAAM,mBAAmB;AACzB,IAAM,qBAAqB;AAEpB,IAAM,kBAAN,MAAM,yBAAwB,cAGnC;AAAA,EACA,OAAgB,KAAK;AAAA,EAErB,OAAO,OAAO,OAAgB,KAAyC;AACrE,UAAM,SAAS,aAAa,MAAM,KAAK;AACvC,WAAO,IAAI;AAAA,MACT;AAAA,QACE,cAAc,OAAO;AAAA,QACrB,UAAU,OAAO;AAAA,QACjB,WAAW,OAAO;AAAA,QAClB,mBAAmB,OAAO;AAAA,QAC1B,oBAAoB,OAAO;AAAA,MAC7B;AAAA,MACA,EAAE,WAAW,OAAO,UAAU;AAAA,MAC9B;AAAA,IACF;AAAA,EACF;AAAA,EAES,KAAK;AAAA,EACI,cAAc;AAAA,EAExB,eAAuC;AAC7C,WAAO;AAAA,MACL,eAAe,UAAU,KAAK,MAAM,SAAS;AAAA,MAC7C,cAAc;AAAA,IAChB;AAAA,EACF;AAAA,EAEQ,MACN,KACA,UACA,QAC0B;AAC1B,WAAO,KAAK,IAAO,KAAK;AAAA,MACtB;AAAA,MACA,SAAS,KAAK,aAAa;AAAA,MAC3B;AAAA,MACA,WAAW;AAAA,IACb,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAMQ,kBAAkB,UAAmC;AAC3D,UAAM,UAAU,KAAK,SAAS;AAC9B,QAAI,CAAC,WAAW,QAAQ,WAAW,GAAG;AACpC,aAAO;AAAA,IACT;AACA,WAAO,QAAQ,SAAS,QAAQ;AAAA,EAClC;AAAA,EAEQ,eAA8B;AACpC,UAAM,SAAwB,CAAC;AAC/B,QACE,KAAK,kBAAkB,QAAQ,KAC/B,KAAK,kBAAkB,cAAc,GACrC;AACA,aAAO,KAAK,QAAQ;AAAA,IACtB;AACA,QAAI,KAAK,kBAAkB,UAAU,GAAG;AACtC,aAAO,KAAK,UAAU;AAAA,IACxB;AACA,QAAI,KAAK,kBAAkB,iBAAiB,GAAG;AAC7C,aAAO,KAAK,aAAa;AAAA,IAC3B;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAMQ,gBAAgB,OAAmC;AACzD,UAAM,MAAM,KAAK,SAAS;AAC1B,YAAQ,OAAO;AAAA,MACb,KAAK;AACH,eAAO,wBAAwB,GAAG;AAAA,MACpC,KAAK;AACH,eAAO,wBAAwB,GAAG;AAAA,MACpC,KAAK;AACH,eAAO;AAAA,IACX;AAAA,EACF;AAAA,EAEQ,gBACN,OACA,SACe;AACf,QAAI,YAAY,MAAM;AACpB,aAAO;AAAA,IACT;AACA,UAAM,cAAc,KAAK,gBAAgB,KAAK;AAC9C,QAAI,gBAAgB,MAAM;AACxB,aAAO;AAAA,IACT;AACA,QAAI;AACF,YAAM,IAAI,IAAI,IAAI,OAAO;AACzB,UACE,EAAE,aAAa,YACf,EAAE,SAAS,mBACX,EAAE,aAAa,aACf;AACA,eAAO;AAAA,MACT;AACA,aAAO,EAAE,SAAS;AAAA,IACpB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEQ,cAAc,QAA+C;AACnE,QAAI,CAAC,mBAAmB,MAAM,GAAG;AAC/B,aAAO;AAAA,IACT;AACA,WAAO;AAAA,MACL,OAAO,OAAO;AAAA,MACd,MAAM,KAAK,gBAAgB,OAAO,OAAO,OAAO,IAAI;AAAA,IACtD;AAAA,EACF;AAAA,EAEQ,sBAAsB,SAA8B;AAC1D,UAAM,IAAI,IAAI;AAAA,MACZ,GAAG,eAAe,kBAAkB,KAAK,SAAS,YAAY;AAAA,IAChE;AACA,MAAE,aAAa,IAAI,SAAS,OAAO,gBAAgB,CAAC;AACpD,MAAE,aAAa,IAAI,QAAQ,MAAM;AACjC,eAAW,WAAW,KAAK,SAAS,YAAY,CAAC,GAAG;AAClD,QAAE,aAAa,OAAO,WAAW,OAAO;AAAA,IAC1C;AACA,QAAI,QAAQ,SAAS,YAAY,QAAQ,OAAO;AAC9C,QAAE,aAAa,IAAI,SAAS,aAAa,QAAQ,KAAK,EAAE;AAAA,IAC1D;AACA,WAAO,EAAE,SAAS;AAAA,EACpB;AAAA,EAEQ,0BAAkC;AACxC,UAAM,IAAI,IAAI;AAAA,MACZ,GAAG,eAAe,kBAAkB,KAAK,SAAS,YAAY;AAAA,IAChE;AACA,MAAE,aAAa,IAAI,YAAY,OAAO,kBAAkB,CAAC;AACzD,eAAW,WAAW,KAAK,SAAS,YAAY,CAAC,GAAG;AAClD,QAAE,aAAa,OAAO,WAAW,OAAO;AAAA,IAC1C;AACA,WAAO,EAAE,SAAS;AAAA,EACpB;AAAA,EAEQ,gBAAwB;AAC9B,UAAM,WACJ,KAAK,SAAS,sBAAsB;AACtC,UAAM,IAAI,IAAI;AAAA,MACZ,GAAG,eAAe,kBAAkB,KAAK,SAAS,YAAY;AAAA,IAChE;AACA,MAAE,aAAa,IAAI,SAAS,eAAe;AAC3C,MAAE,aAAa,IAAI,YAAY,OAAO;AACtC,MAAE,aAAa,IAAI,YAAY,IAAI;AACnC,MAAE,aAAa,IAAI,eAAe,GAAG,QAAQ,GAAG;AAChD,MAAE,aAAa,OAAO,WAAW,SAAS;AAC1C,eAAW,WAAW,KAAK,SAAS,YAAY,CAAC,GAAG;AAClD,QAAE,aAAa,OAAO,WAAW,OAAO;AAAA,IAC1C;AACA,WAAO,EAAE,SAAS;AAAA,EACpB;AAAA,EAEQ,oBAAoB,SAAyB;AACnD,UAAM,MAAM,KAAK,SAAS,qBAAqB;AAC/C,UAAM,IAAI,IAAI,IAAI,GAAG,eAAe,WAAW,OAAO,UAAU;AAChE,MAAE,aAAa;AAAA,MACb;AAAA,MACA,OAAO,KAAK,IAAI,KAAK,wBAAwB,CAAC;AAAA,IAChD;AACA,WAAO,EAAE,SAAS;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA,EAMA,MAAc,gBACZ,MACA,SACA,QAC2D;AAC3D,UAAM,MAAM,QAAQ,KAAK,sBAAsB,OAAO;AACtD,UAAM,MAAM,MAAM,KAAK,MAAqB,KAAK,UAAU,MAAM;AAEjE,UAAM,WAAW,gBAAgB,IAAI,QAAQ,IAAI,MAAM,GAAG,MAAM;AAChE,UAAM,OACJ,YAAY,SAAS,aACjB,KAAK,gBAAgB,UAAU,SAAS,GAAG,IAC3C;AAEN,UAAM,gBAAgB,oBAAI,IAAgC;AAC1D,QAAI,KAAK,kBAAkB,cAAc,GAAG;AAC1C,iBAAW,SAAS,IAAI,MAAM;AAC5B,gBAAQ,eAAe;AACvB,cAAM,YAAY,MAAM,KAAK;AAAA,UAC3B,KAAK,oBAAoB,MAAM,EAAE;AAAA,UACjC;AAAA,UACA;AAAA,QACF;AACA,sBAAc,IAAI,MAAM,IAAI,UAAU,IAAI;AAAA,MAC5C;AAAA,IACF;AAEA,WAAO,EAAE,OAAO,CAAC,EAAE,QAAQ,IAAI,MAAM,cAAc,CAAC,GAAG,KAAK;AAAA,EAC9D;AAAA,EAEA,MAAc,kBACZ,MACA,QAC0D;AAC1D,UAAM,MAAM,QAAQ,KAAK,wBAAwB;AACjD,UAAM,MAAM,MAAM,KAAK,MAAuB,KAAK,YAAY,MAAM;AACrE,UAAM,WAAW,gBAAgB,IAAI,QAAQ,IAAI,MAAM,GAAG,MAAM;AAChE,UAAM,OACJ,YAAY,SAAS,aACjB,KAAK,gBAAgB,YAAY,SAAS,GAAG,IAC7C;AACN,WAAO,EAAE,OAAO,IAAI,MAAM,KAAK;AAAA,EACjC;AAAA,EAEA,MAAc,gBACZ,QACgE;AAChE,UAAM,MAAM,MAAM,KAAK;AAAA,MACrB,KAAK,cAAc;AAAA,MACnB;AAAA,MACA;AAAA,IACF;AACA,WAAO,EAAE,OAAO,CAAC,IAAI,IAAI,GAAG,MAAM,KAAK;AAAA,EACzC;AAAA;AAAA;AAAA;AAAA,EAMA,MAAc,gBACZ,SACA,MACe;AACf,UAAM,gBAAgB,KAAK,kBAAkB,QAAQ;AACrD,UAAM,cAAc,KAAK,kBAAkB,cAAc;AAEzD,eAAW,SAAS,KAAK,QAAQ;AAC/B,UAAI,eAAe;AACjB,cAAM,QACJ,OAAO,MAAM,UAAU,WAAW,OAAO,MAAM,KAAK,IAAI,MAAM;AAChE,cAAM,cAAc,cAAc,MAAM,SAAS;AACjD,cAAM,aAAa,cAAc,MAAM,QAAQ;AAC/C,YAAI,gBAAgB,QAAQ,eAAe,MAAM;AAC/C,kBAAQ;AAAA,YACN,qCAAqC,MAAM,EAAE;AAAA,UAC/C;AAAA,QACF,OAAO;AACL,gBAAM,QAAQ,OAAO;AAAA,YACnB,MAAM;AAAA,YACN,IAAI,MAAM;AAAA,YACV,YAAY;AAAA,cACV,SAAS,MAAM;AAAA,cACf,OAAO,MAAM;AAAA,cACb,OAAO,MAAM;AAAA,cACb,QAAQ,MAAM;AAAA,cACd,WAAW;AAAA,cACX,UAAU;AAAA,cACV,OAAO,OAAO,SAAS,KAAK,IAAI,QAAQ;AAAA,cACxC,WAAW,MAAM;AAAA,cACjB,aAAa,MAAM,QAAQ;AAAA,YAC7B;AAAA,YACA,YAAY;AAAA,UACd,CAAC;AAAA,QACH;AAAA,MACF;AAEA,UAAI,aAAa;AACf,cAAM,SAAS,KAAK,cAAc,IAAI,MAAM,EAAE,KAAK,CAAC;AACpD,mBAAW,MAAM,QAAQ;AACvB,gBAAM,UAAU,GAAG,WAAW,GAAG,MAAM;AACvC,cAAI,YAAY,MAAM;AACpB;AAAA,UACF;AACA,gBAAM,UAAU,cAAc,GAAG,WAAW;AAC5C,cAAI,YAAY,MAAM;AACpB;AAAA,UACF;AACA,gBAAM,QAAQ,MAAM;AAAA,YAClB,MAAM;AAAA,YACN,UAAU;AAAA,YACV,QAAQ;AAAA,YACR,YAAY;AAAA,cACV;AAAA,cACA,SAAS,MAAM;AAAA,cACf,cAAc,MAAM;AAAA,cACpB,aAAa,MAAM,QAAQ;AAAA,cAC3B,OAAO,MAAM;AAAA,cACb,UAAU,GAAG,YAAY;AAAA,cACzB,aAAa,GAAG,eAAe;AAAA,cAC/B,SAAS,GAAG,WAAW;AAAA,YACzB;AAAA,UACF,CAAC;AAAA,QACH;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAc,cACZ,SACA,UACe;AACf,eAAW,KAAK,UAAU;AACxB,YAAM,YAAY,cAAc,EAAE,WAAW;AAC7C,YAAM,aAAa,cAAc,EAAE,YAAY;AAC/C,YAAM,cAAc,cAAc,EAAE,SAAS;AAC7C,UAAI,cAAc,MAAM;AACtB,gBAAQ;AAAA,UACN,uCAAuC,EAAE,OAAO;AAAA,QAClD;AACA;AAAA,MACF;AACA,YAAM,QAAQ,OAAO;AAAA,QACnB,MAAM;AAAA,QACN,IAAI,EAAE;AAAA,QACN,YAAY;AAAA,UACV,SAAS,EAAE;AAAA,UACX,UAAU,EAAE,SAAS,IAAI,CAAC,MAAM,EAAE,IAAI;AAAA,UACtC,aAAa;AAAA,UACb,cAAc;AAAA,UACd,WAAW;AAAA,QACb;AAAA,QACA,YAAY,KAAK,IAAI,WAAW,cAAc,GAAG,eAAe,CAAC;AAAA,MACnE,CAAC;AAAA,IACH;AAAA,EACF;AAAA,EAEA,MAAc,gBACZ,SACA,OACe;AACf,UAAM,UAKD,CAAC;AACN,eAAW,SAAS,MAAM,QAAQ;AAChC,YAAM,UAAU,MAAM,GAAG,SAAS;AAClC,YAAM,aAAa,YAAY,SAAY,OAAO,OAAO,IAAI;AAC7D,YAAM,SAAS,MAAM,OAAO,eAAe,KAAK,CAAC;AACjD,eAAS,IAAI,GAAG,IAAI,MAAM,UAAU,QAAQ,KAAK;AAC/C,cAAM,cAAc,MAAM,UAAU,CAAC;AACrC,cAAM,WAAW,OAAO,CAAC;AACzB,YAAI,gBAAgB,UAAa,aAAa,QAAW;AACvD;AAAA,QACF;AACA,cAAM,KAAK,IAAI,KAAK,WAAW,EAAE,QAAQ;AACzC,cAAM,QAAQ,OAAO,QAAQ;AAC7B,YAAI,CAAC,OAAO,SAAS,EAAE,KAAK,CAAC,OAAO,SAAS,KAAK,GAAG;AACnD;AAAA,QACF;AACA,gBAAQ,KAAK;AAAA,UACX,MAAM;AAAA,UACN;AAAA,UACA;AAAA,UACA,YAAY,EAAE,SAAS,WAAW;AAAA,QACpC,CAAC;AAAA,MACH;AAAA,IACF;AACA,UAAM,QAAQ,QAAQ,SAAS,EAAE,OAAO,CAAC,wBAAwB,EAAE,CAAC;AAAA,EACtE;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,KACJ,SACA,SACA,QACqB;AACrB,UAAM,SAAS,KAAK,cAAc,QAAQ,MAAM;AAChD,UAAM,SAAS,QAAQ,SAAS;AAChC,UAAM,SAAS,KAAK,aAAa;AAEjC,WAAO,gBAAqC;AAAA,MAC1C;AAAA,MACA;AAAA,MACA;AAAA,MACA,WAAW,OAAO,OAAO,MAAM,QAAQ;AACrC,gBAAQ,OAAO;AAAA,UACb,KAAK;AACH,mBAAO,KAAK,gBAAgB,MAAM,SAAS,GAAG;AAAA,UAChD,KAAK;AACH,mBAAO,KAAK,kBAAkB,MAAM,GAAG;AAAA,UACzC,KAAK;AACH,mBAAO,KAAK,gBAAgB,GAAG;AAAA,QACnC;AAAA,MACF;AAAA,MACA,YAAY,OAAO,OAAO,OAAO,SAAS;AACxC,YAAI,UAAU,SAAS,MAAM;AAC3B,kBAAQ,OAAO;AAAA,YACb,KAAK;AACH,kBAAI,KAAK,kBAAkB,QAAQ,GAAG;AACpC,sBAAM,QAAQ,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,cAAc,EAAE,CAAC;AAAA,cACxD;AACA,kBAAI,KAAK,kBAAkB,cAAc,GAAG;AAC1C,sBAAM,QAAQ,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,EAAE,CAAC;AAAA,cAC5D;AACA;AAAA,YACF,KAAK;AACH,oBAAM,QAAQ,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,gBAAgB,EAAE,CAAC;AACxD;AAAA,YACF,KAAK;AACH;AAAA,UACJ;AAAA,QACF;AACA,gBAAQ,OAAO;AAAA,UACb,KAAK;AACH,uBAAW,QAAQ,OAA2B;AAC5C,oBAAM,KAAK,gBAAgB,SAAS,IAAI;AAAA,YAC1C;AACA;AAAA,UACF,KAAK;AACH,mBAAO,KAAK,cAAc,SAAS,KAAwB;AAAA,UAC7D,KAAK;AACH,uBAAW,SAAS,OAAgC;AAClD,oBAAM,KAAK,gBAAgB,SAAS,KAAK;AAAA,YAC3C;AACA;AAAA,QACJ;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH;AACF;;;AChpBA,IAAO,gBAAQ;","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.14.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.14.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.2.0",
34
- "@rawdash/connector-test-utils": "0.0.1"
33
+ "@rawdash/connector-shared": "0.3.0",
34
+ "@rawdash/connector-test-utils": "0.0.3"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsup",