@stravigor/devtools 0.1.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.
@@ -0,0 +1,195 @@
1
+ import type { SQL } from 'bun'
2
+ import type { AggregateRecord, AggregateFunction } from '../types.ts'
3
+
4
+ /** Aggregation periods in seconds. */
5
+ export const PERIODS = {
6
+ ONE_HOUR: 3600,
7
+ SIX_HOURS: 21600,
8
+ ONE_DAY: 86400,
9
+ SEVEN_DAYS: 604800,
10
+ } as const
11
+
12
+ /**
13
+ * Stores and queries pre-aggregated metric buckets in `_strav_devtools_aggregates`.
14
+ *
15
+ * Used by recorders to store slow request counts, slow query counts, etc.
16
+ * The dashboard reads these for time-series charts without scanning raw entries.
17
+ */
18
+ export default class AggregateStore {
19
+ constructor(private sql: SQL) {}
20
+
21
+ /** Create the aggregates table if it doesn't exist. */
22
+ async ensureTable(): Promise<void> {
23
+ await this.sql`
24
+ CREATE TABLE IF NOT EXISTS "_strav_devtools_aggregates" (
25
+ "id" BIGSERIAL PRIMARY KEY,
26
+ "bucket" INT NOT NULL,
27
+ "period" INT NOT NULL,
28
+ "type" VARCHAR(30) NOT NULL,
29
+ "key" TEXT NOT NULL DEFAULT '',
30
+ "aggregate" VARCHAR(10) NOT NULL,
31
+ "value" NUMERIC(20, 2) NOT NULL DEFAULT 0,
32
+ "count" INT,
33
+ UNIQUE ("bucket", "period", "type", "aggregate", "key")
34
+ )
35
+ `
36
+
37
+ await this.sql`
38
+ CREATE INDEX IF NOT EXISTS "idx_strav_devtools_aggregates_lookup"
39
+ ON "_strav_devtools_aggregates" ("type", "period", "bucket" DESC)
40
+ `
41
+ }
42
+
43
+ /**
44
+ * Record a value into the appropriate time buckets.
45
+ * Updates existing bucket rows via upsert (INSERT ... ON CONFLICT).
46
+ */
47
+ async record(
48
+ type: string,
49
+ key: string,
50
+ value: number,
51
+ aggregates: AggregateFunction[]
52
+ ): Promise<void> {
53
+ const now = Math.floor(Date.now() / 1000)
54
+
55
+ for (const period of Object.values(PERIODS)) {
56
+ const bucket = Math.floor(now / period) * period
57
+
58
+ for (const agg of aggregates) {
59
+ await this.upsert(bucket, period, type, key, agg, value)
60
+ }
61
+ }
62
+ }
63
+
64
+ /** Query aggregated data for a type over a time range. */
65
+ async query(
66
+ type: string,
67
+ period: number,
68
+ aggregate: AggregateFunction,
69
+ limit = 24
70
+ ): Promise<AggregateRecord[]> {
71
+ const rows = await this.sql`
72
+ SELECT * FROM "_strav_devtools_aggregates"
73
+ WHERE "type" = ${type}
74
+ AND "period" = ${period}
75
+ AND "aggregate" = ${aggregate}
76
+ ORDER BY "bucket" DESC
77
+ LIMIT ${limit}
78
+ `
79
+ return rows.map(hydrateAggregate)
80
+ }
81
+
82
+ /** Query top keys by aggregate value for a type. */
83
+ async topKeys(
84
+ type: string,
85
+ period: number,
86
+ aggregate: AggregateFunction,
87
+ limit = 10
88
+ ): Promise<AggregateRecord[]> {
89
+ const now = Math.floor(Date.now() / 1000)
90
+ const since = now - period
91
+
92
+ const rows = await this.sql`
93
+ SELECT "key", SUM("value") AS "value", SUM("count") AS "count",
94
+ MAX("bucket") AS "bucket", ${period}::int AS "period",
95
+ ${type} AS "type", ${aggregate} AS "aggregate", 0 AS "id"
96
+ FROM "_strav_devtools_aggregates"
97
+ WHERE "type" = ${type}
98
+ AND "period" = ${period}
99
+ AND "aggregate" = ${aggregate}
100
+ AND "bucket" >= ${since}
101
+ GROUP BY "key"
102
+ ORDER BY SUM("value") DESC
103
+ LIMIT ${limit}
104
+ `
105
+ return rows.map(hydrateAggregate)
106
+ }
107
+
108
+ /** Delete aggregates older than the given number of hours. */
109
+ async prune(hours: number): Promise<number> {
110
+ const cutoff = Math.floor(Date.now() / 1000) - hours * 3600
111
+
112
+ const rows = await this.sql`
113
+ DELETE FROM "_strav_devtools_aggregates"
114
+ WHERE "bucket" < ${cutoff}
115
+ `
116
+ return rows.count
117
+ }
118
+
119
+ private async upsert(
120
+ bucket: number,
121
+ period: number,
122
+ type: string,
123
+ key: string,
124
+ aggregate: AggregateFunction,
125
+ value: number
126
+ ): Promise<void> {
127
+ switch (aggregate) {
128
+ case 'count':
129
+ await this.sql`
130
+ INSERT INTO "_strav_devtools_aggregates" ("bucket", "period", "type", "key", "aggregate", "value", "count")
131
+ VALUES (${bucket}, ${period}, ${type}, ${key}, 'count', 1, 1)
132
+ ON CONFLICT ("bucket", "period", "type", "aggregate", "key")
133
+ DO UPDATE SET "value" = "_strav_devtools_aggregates"."value" + 1,
134
+ "count" = COALESCE("_strav_devtools_aggregates"."count", 0) + 1
135
+ `
136
+ break
137
+
138
+ case 'sum':
139
+ await this.sql`
140
+ INSERT INTO "_strav_devtools_aggregates" ("bucket", "period", "type", "key", "aggregate", "value", "count")
141
+ VALUES (${bucket}, ${period}, ${type}, ${key}, 'sum', ${value}, 1)
142
+ ON CONFLICT ("bucket", "period", "type", "aggregate", "key")
143
+ DO UPDATE SET "value" = "_strav_devtools_aggregates"."value" + ${value},
144
+ "count" = COALESCE("_strav_devtools_aggregates"."count", 0) + 1
145
+ `
146
+ break
147
+
148
+ case 'max':
149
+ await this.sql`
150
+ INSERT INTO "_strav_devtools_aggregates" ("bucket", "period", "type", "key", "aggregate", "value", "count")
151
+ VALUES (${bucket}, ${period}, ${type}, ${key}, 'max', ${value}, 1)
152
+ ON CONFLICT ("bucket", "period", "type", "aggregate", "key")
153
+ DO UPDATE SET "value" = GREATEST("_strav_devtools_aggregates"."value", ${value}),
154
+ "count" = COALESCE("_strav_devtools_aggregates"."count", 0) + 1
155
+ `
156
+ break
157
+
158
+ case 'min':
159
+ await this.sql`
160
+ INSERT INTO "_strav_devtools_aggregates" ("bucket", "period", "type", "key", "aggregate", "value", "count")
161
+ VALUES (${bucket}, ${period}, ${type}, ${key}, 'min', ${value}, 1)
162
+ ON CONFLICT ("bucket", "period", "type", "aggregate", "key")
163
+ DO UPDATE SET "value" = LEAST("_strav_devtools_aggregates"."value", ${value}),
164
+ "count" = COALESCE("_strav_devtools_aggregates"."count", 0) + 1
165
+ `
166
+ break
167
+
168
+ case 'avg':
169
+ await this.sql`
170
+ INSERT INTO "_strav_devtools_aggregates" ("bucket", "period", "type", "key", "aggregate", "value", "count")
171
+ VALUES (${bucket}, ${period}, ${type}, ${key}, 'avg', ${value}, 1)
172
+ ON CONFLICT ("bucket", "period", "type", "aggregate", "key")
173
+ DO UPDATE SET "value" = (
174
+ "_strav_devtools_aggregates"."value" * COALESCE("_strav_devtools_aggregates"."count", 1)
175
+ + ${value}
176
+ ) / (COALESCE("_strav_devtools_aggregates"."count", 1) + 1),
177
+ "count" = COALESCE("_strav_devtools_aggregates"."count", 0) + 1
178
+ `
179
+ break
180
+ }
181
+ }
182
+ }
183
+
184
+ function hydrateAggregate(row: Record<string, unknown>): AggregateRecord {
185
+ return {
186
+ id: Number(row.id),
187
+ bucket: Number(row.bucket),
188
+ period: Number(row.period),
189
+ type: row.type as string,
190
+ key: row.key as string,
191
+ aggregate: row.aggregate as AggregateFunction,
192
+ value: Number(row.value),
193
+ count: row.count != null ? Number(row.count) : null,
194
+ }
195
+ }
@@ -0,0 +1,160 @@
1
+ import type { SQL } from 'bun'
2
+ import type { DevtoolsEntry, EntryRecord, EntryType } from '../types.ts'
3
+
4
+ /**
5
+ * Stores and queries raw devtools entries in `_strav_devtools_entries`.
6
+ *
7
+ * Each entry represents a single recorded event (request, query, exception, etc.)
8
+ * and belongs to a batch (all entries from a single request/job share a batchId).
9
+ */
10
+ export default class EntryStore {
11
+ constructor(private sql: SQL) {}
12
+
13
+ /** Create the entries table if it doesn't exist. */
14
+ async ensureTable(): Promise<void> {
15
+ await this.sql`
16
+ CREATE TABLE IF NOT EXISTS "_strav_devtools_entries" (
17
+ "id" BIGSERIAL PRIMARY KEY,
18
+ "uuid" UUID NOT NULL,
19
+ "batch_id" UUID NOT NULL,
20
+ "type" VARCHAR(30) NOT NULL,
21
+ "family_hash" VARCHAR(64),
22
+ "content" JSONB NOT NULL DEFAULT '{}',
23
+ "tags" TEXT[] NOT NULL DEFAULT '{}',
24
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
25
+ )
26
+ `
27
+
28
+ await this.sql`
29
+ CREATE INDEX IF NOT EXISTS "idx_strav_devtools_entries_batch"
30
+ ON "_strav_devtools_entries" ("batch_id")
31
+ `
32
+
33
+ await this.sql`
34
+ CREATE INDEX IF NOT EXISTS "idx_strav_devtools_entries_type_created"
35
+ ON "_strav_devtools_entries" ("type", "created_at" DESC)
36
+ `
37
+
38
+ await this.sql`
39
+ CREATE INDEX IF NOT EXISTS "idx_strav_devtools_entries_family_hash"
40
+ ON "_strav_devtools_entries" ("family_hash")
41
+ WHERE "family_hash" IS NOT NULL
42
+ `
43
+ }
44
+
45
+ /** Insert one or more entries. */
46
+ async store(entries: DevtoolsEntry[]): Promise<void> {
47
+ if (entries.length === 0) return
48
+
49
+ for (const entry of entries) {
50
+ const tagsLiteral = `{${entry.tags.map(t => `"${t.replace(/"/g, '\\"')}"`).join(',')}}`
51
+ await this.sql`
52
+ INSERT INTO "_strav_devtools_entries"
53
+ ("uuid", "batch_id", "type", "family_hash", "content", "tags", "created_at")
54
+ VALUES (
55
+ ${entry.uuid},
56
+ ${entry.batchId},
57
+ ${entry.type},
58
+ ${entry.familyHash},
59
+ ${JSON.stringify(entry.content)},
60
+ ${tagsLiteral}::TEXT[],
61
+ ${entry.createdAt}
62
+ )
63
+ `
64
+ }
65
+ }
66
+
67
+ /** List entries by type, most recent first. */
68
+ async list(type?: EntryType, limit = 50, offset = 0): Promise<EntryRecord[]> {
69
+ let rows
70
+
71
+ if (type) {
72
+ rows = await this.sql`
73
+ SELECT * FROM "_strav_devtools_entries"
74
+ WHERE "type" = ${type}
75
+ ORDER BY "created_at" DESC
76
+ LIMIT ${limit} OFFSET ${offset}
77
+ `
78
+ } else {
79
+ rows = await this.sql`
80
+ SELECT * FROM "_strav_devtools_entries"
81
+ ORDER BY "created_at" DESC
82
+ LIMIT ${limit} OFFSET ${offset}
83
+ `
84
+ }
85
+
86
+ return rows.map(hydrateEntry)
87
+ }
88
+
89
+ /** Find a single entry by UUID. */
90
+ async find(uuid: string): Promise<EntryRecord | null> {
91
+ const rows = await this.sql`
92
+ SELECT * FROM "_strav_devtools_entries"
93
+ WHERE "uuid" = ${uuid}
94
+ LIMIT 1
95
+ `
96
+ return rows.length > 0 ? hydrateEntry(rows[0] as Record<string, unknown>) : null
97
+ }
98
+
99
+ /** Find all entries in a batch, for cross-referencing. */
100
+ async batch(batchId: string): Promise<EntryRecord[]> {
101
+ const rows = await this.sql`
102
+ SELECT * FROM "_strav_devtools_entries"
103
+ WHERE "batch_id" = ${batchId}
104
+ ORDER BY "created_at" ASC
105
+ `
106
+ return rows.map(hydrateEntry)
107
+ }
108
+
109
+ /** Search entries by tag. */
110
+ async byTag(tag: string, limit = 50): Promise<EntryRecord[]> {
111
+ const rows = await this.sql`
112
+ SELECT * FROM "_strav_devtools_entries"
113
+ WHERE ${tag} = ANY("tags")
114
+ ORDER BY "created_at" DESC
115
+ LIMIT ${limit}
116
+ `
117
+ return rows.map(hydrateEntry)
118
+ }
119
+
120
+ /** Delete entries older than the given number of hours. */
121
+ async prune(hours: number): Promise<number> {
122
+ const rows = await this.sql`
123
+ DELETE FROM "_strav_devtools_entries"
124
+ WHERE "created_at" < NOW() - MAKE_INTERVAL(hours => ${hours})
125
+ `
126
+ return rows.count
127
+ }
128
+
129
+ /** Count entries, optionally filtered by type. */
130
+ async count(type?: EntryType): Promise<number> {
131
+ if (type) {
132
+ const rows = await this.sql`
133
+ SELECT COUNT(*)::int AS count FROM "_strav_devtools_entries"
134
+ WHERE "type" = ${type}
135
+ `
136
+ return (rows[0] as Record<string, unknown>).count as number
137
+ }
138
+
139
+ const rows = await this.sql`
140
+ SELECT COUNT(*)::int AS count FROM "_strav_devtools_entries"
141
+ `
142
+ return (rows[0] as Record<string, unknown>).count as number
143
+ }
144
+ }
145
+
146
+ function hydrateEntry(row: Record<string, unknown>): EntryRecord {
147
+ return {
148
+ id: Number(row.id),
149
+ uuid: row.uuid as string,
150
+ batchId: row.batch_id as string,
151
+ type: row.type as EntryType,
152
+ familyHash: (row.family_hash as string) ?? null,
153
+ content: (typeof row.content === 'string' ? JSON.parse(row.content) : row.content) as Record<
154
+ string,
155
+ unknown
156
+ >,
157
+ tags: (row.tags ?? []) as string[],
158
+ createdAt: row.created_at as Date,
159
+ }
160
+ }
package/src/types.ts ADDED
@@ -0,0 +1,81 @@
1
+ /** Types of entries that collectors produce. */
2
+ export type EntryType =
3
+ | 'request'
4
+ | 'query'
5
+ | 'exception'
6
+ | 'log'
7
+ | 'job'
8
+ | 'cache'
9
+ | 'mail'
10
+ | 'event'
11
+ | 'schedule'
12
+
13
+ /** A single recorded devtools entry, ready for storage. */
14
+ export interface DevtoolsEntry {
15
+ uuid: string
16
+ batchId: string
17
+ type: EntryType
18
+ familyHash: string | null
19
+ content: Record<string, unknown>
20
+ tags: string[]
21
+ createdAt: Date
22
+ }
23
+
24
+ /** A row from the _strav_devtools_entries table. */
25
+ export interface EntryRecord {
26
+ id: number
27
+ uuid: string
28
+ batchId: string
29
+ type: EntryType
30
+ familyHash: string | null
31
+ content: Record<string, unknown>
32
+ tags: string[]
33
+ createdAt: Date
34
+ }
35
+
36
+ /** Aggregate function names stored in the aggregates table. */
37
+ export type AggregateFunction = 'count' | 'min' | 'max' | 'sum' | 'avg'
38
+
39
+ /** A row from the _strav_devtools_aggregates table. */
40
+ export interface AggregateRecord {
41
+ id: number
42
+ bucket: number
43
+ period: number
44
+ type: string
45
+ key: string
46
+ aggregate: AggregateFunction
47
+ value: number
48
+ count: number | null
49
+ }
50
+
51
+ /** Configuration shape for the devtools package. */
52
+ export interface DevtoolsConfig {
53
+ enabled: boolean
54
+ storage: {
55
+ pruneAfter: number
56
+ }
57
+ collectors: {
58
+ request: { enabled: boolean; sizeLimit: number }
59
+ query: { enabled: boolean; slow: number }
60
+ exception: { enabled: boolean }
61
+ log: { enabled: boolean; level: string }
62
+ job: { enabled: boolean }
63
+ }
64
+ recorders: {
65
+ slowRequests: { enabled: boolean; threshold: number; sampleRate: number }
66
+ slowQueries: { enabled: boolean; threshold: number; sampleRate: number }
67
+ }
68
+ }
69
+
70
+ /** Collector configuration passed to each collector. */
71
+ export interface CollectorOptions {
72
+ enabled: boolean
73
+ [key: string]: unknown
74
+ }
75
+
76
+ /** Recorder configuration passed to each recorder. */
77
+ export interface RecorderOptions {
78
+ enabled: boolean
79
+ threshold?: number
80
+ sampleRate?: number
81
+ }
@@ -0,0 +1,24 @@
1
+ import { env } from '@stravigor/core/helpers'
2
+
3
+ export default {
4
+ /** Enable or disable devtools entirely. */
5
+ enabled: env('DEVTOOLS_ENABLED', 'true').bool(),
6
+
7
+ storage: {
8
+ /** Automatically prune entries older than this many hours. */
9
+ pruneAfter: 24,
10
+ },
11
+
12
+ collectors: {
13
+ request: { enabled: true, sizeLimit: 64 },
14
+ query: { enabled: true, slow: 100 },
15
+ exception: { enabled: true },
16
+ log: { enabled: true, level: 'debug' },
17
+ job: { enabled: true },
18
+ },
19
+
20
+ recorders: {
21
+ slowRequests: { enabled: true, threshold: 1000, sampleRate: 1.0 },
22
+ slowQueries: { enabled: true, threshold: 1000, sampleRate: 1.0 },
23
+ },
24
+ }
@@ -0,0 +1,14 @@
1
+ import { defineSchema, t, Archetype } from '@stravigor/core/schema'
2
+
3
+ export default defineSchema('_strav_devtools_aggregates', {
4
+ archetype: Archetype.Event,
5
+ fields: {
6
+ bucket: t.integer().required(),
7
+ period: t.integer().required(),
8
+ type: t.varchar(30).required(),
9
+ key: t.text().required(),
10
+ aggregate: t.varchar(10).required(),
11
+ value: t.numeric(20, 2).required(),
12
+ count: t.integer().nullable(),
13
+ },
14
+ })
@@ -0,0 +1,13 @@
1
+ import { defineSchema, t, Archetype } from '@stravigor/core/schema'
2
+
3
+ export default defineSchema('_strav_devtools_entries', {
4
+ archetype: Archetype.Event,
5
+ fields: {
6
+ uuid: t.uuid().required(),
7
+ batchId: t.uuid().required().index(),
8
+ type: t.varchar(30).required().index(),
9
+ familyHash: t.varchar(64).nullable().index(),
10
+ content: t.jsonb().required(),
11
+ tags: t.text().array().required(),
12
+ },
13
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }