wide_events 0.1.4 → 0.2.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +149 -8
- data/lib/generators/wide_events/install/install_generator.rb +16 -2
- data/lib/generators/wide_events/skill_installer.rb +24 -0
- data/lib/generators/wide_events/skills/skills_generator.rb +9 -7
- data/lib/generators/wide_events/store/store_generator.rb +145 -0
- data/lib/generators/wide_events/store/templates/accessory.yml.erb +33 -0
- data/lib/wide_event/configuration.rb +64 -3
- data/lib/wide_event/job_instrumentation.rb +2 -0
- data/lib/wide_event/kamal/deploy_editor.rb +499 -0
- data/lib/wide_event/kamal/secrets_editor.rb +103 -0
- data/lib/wide_event/middleware.rb +2 -0
- data/lib/wide_event/railtie.rb +8 -0
- data/lib/wide_event/registry/defaults.yml +5 -0
- data/lib/wide_event/setup/checker.rb +402 -0
- data/lib/wide_event/setup/command_runner.rb +25 -0
- data/lib/wide_event/sinks/store.rb +33 -0
- data/lib/wide_event/store/client.rb +258 -0
- data/lib/wide_event/store/envelope.rb +60 -0
- data/lib/wide_event/store/formatter.rb +60 -0
- data/lib/wide_event/store/query_result.rb +19 -0
- data/lib/wide_event/store/sender.rb +448 -0
- data/lib/wide_event/tasks/setup.rake +55 -0
- data/lib/wide_event/tasks/store.rake +122 -0
- data/lib/wide_event/version.rb +1 -1
- data/lib/wide_event.rb +21 -0
- data/skills/debugging-with-wide-events/SKILL.md +92 -7
- data/skills/setting-up-wide-events-store/SKILL.md +120 -0
- metadata +36 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea7b42dac2182e0b1c22f607629658f019943cf773cfc0398cdcfedbf9d4b311
|
|
4
|
+
data.tar.gz: 300d00038776858fce62fa359e9f08f6432d2fe3955fbf8b45f9a10480c73da5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 393e3e6d00481ddc6500e4f1d9e1dd4d332f3d97a027b80e13008e8dec15ef5dc95e3a8a56d10780fbb847cb23fd7173d36764a3ec20489ca643c574e2496902
|
|
7
|
+
data.tar.gz: 22c83a1f6b26e27e0ede2e6e79aaa4cab933f188792e499e4c608844259d7c331f7f608bfcdf5d8456d9e1f7881b2fd1fdfe7b9e8e62cc7970baaf652ca3460c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.2.1 (2026-08-06)
|
|
4
|
+
|
|
5
|
+
- Fix the store image pin in the README. The v0.2.0 release rewrite turned
|
|
6
|
+
`ghcr.io/adammiribyan/wide-events-store:0.2.0` into `.2.0`.
|
|
7
|
+
|
|
8
|
+
## v0.2.0 (2026-08-06)
|
|
9
|
+
|
|
10
|
+
- **A bundled telemetry store.** `bin/rails generate wide_events:store
|
|
11
|
+
--host --hostname --retention-days` writes a Kamal accessory for
|
|
12
|
+
`ghcr.io/adammiribyan/wide-events-store:<version>`, a single-process Go
|
|
13
|
+
service around DuckDB, plus durable ingest and query tokens under
|
|
14
|
+
`.kamal/`. `kamal setup` deploys it and `kamal telemetry` opens a SQL
|
|
15
|
+
prompt against production. No collector and no OpenTelemetry SDK
|
|
16
|
+
required; `:otel` and `:log` keep working unchanged.
|
|
17
|
+
- The `:store` sink batches, gzips, and delivers events on one background
|
|
18
|
+
thread per process, behind a bounded queue (1,000 events or 8 MiB) that
|
|
19
|
+
drops oldest-first and reports the count with the next batch. Batches are
|
|
20
|
+
idempotent by `batch_id`, so a response lost after the store committed is
|
|
21
|
+
retried and deduplicated instead of double-counted. The sender is
|
|
22
|
+
fork-aware.
|
|
23
|
+
- `WIDE_EVENTS_URL` alone enables the gem and selects the store sink,
|
|
24
|
+
ahead of `OTEL_EXPORTER_OTLP_ENDPOINT`. `WIDE_EVENTS_SERVICE`,
|
|
25
|
+
`WIDE_EVENTS_ENVIRONMENT`, `WIDE_EVENTS_INGEST_TOKEN`, and
|
|
26
|
+
`WIDE_EVENTS_QUERY_TOKEN` complete the configuration; ingest and query
|
|
27
|
+
tokens must differ.
|
|
28
|
+
- The store keeps `route`, `job_class`, `status`, `error`, `request_id`,
|
|
29
|
+
`duration_ms`, `kind`, `deployment`, and `source` as real columns and
|
|
30
|
+
everything else in an `attributes` VARIANT, read as
|
|
31
|
+
`attributes['key.name']::VARCHAR`. `wide_events_store_status` is the
|
|
32
|
+
one-row operational view. Queries are read-only and bounded: 2 running,
|
|
33
|
+
4 waiting, 15s, 10,000 rows, 10 MiB.
|
|
34
|
+
- Retention deletes events past `WIDE_EVENTS_RETENTION_DAYS` daily, a
|
|
35
|
+
verified local backup runs alongside it (three newest kept, or on demand
|
|
36
|
+
via `kamal wide-events-backup`), and the store reserves the greater of
|
|
37
|
+
2 GiB or 10% of the volume as a write floor. Below it, ingest answers 507
|
|
38
|
+
while `/up` and queries keep working.
|
|
39
|
+
- `bin/rails wide_events:setup:check` is the idempotent resume command for
|
|
40
|
+
the whole setup: Kamal version and config, host directory permissions,
|
|
41
|
+
DNS, and a synthetic round trip through the deployed endpoint.
|
|
42
|
+
- A third bundled skill, `setting-up-wide-events-store`, walks an agent
|
|
43
|
+
through the deploy. `debugging-with-wide-events` gained the store's data
|
|
44
|
+
model and standing queries.
|
|
45
|
+
- The generated accessory raises `minimum_version` to `2.10.0`: its
|
|
46
|
+
structured `directories` entry carries the `mode` and `owner` the DuckDB
|
|
47
|
+
volume needs, and older Kamal releases reject it.
|
|
48
|
+
|
|
3
49
|
## v0.1.4 (2026-08-04)
|
|
4
50
|
|
|
5
51
|
- The railtie stores `registry_path` relative to the app root
|
data/README.md
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
One wide telemetry event per Rails request or job execution, in a database you own.
|
|
7
7
|
|
|
8
|
-
Wide Events collects everything your app knows about each unit of work (route, user, account, build SHA, query counts, cache hits, feature flags, phase timings, errors) into one flat, high-cardinality event
|
|
8
|
+
Wide Events collects everything your app knows about each unit of work (route, user, account, build SHA, query counts, cache hits, feature flags, phase timings, errors) into one flat, high-cardinality event. Every request becomes one row, and every question becomes one query against storage you run.
|
|
9
|
+
|
|
10
|
+
Three places to put those rows, in order of how much you have to operate:
|
|
11
|
+
|
|
12
|
+
- **The bundled store.** A DuckDB service the gem ships as a Kamal accessory: one generator, one `kamal setup`, and `kamal telemetry` gives you a SQL prompt against production. No collector, no OTel SDK.
|
|
13
|
+
- **Any OTLP backend.** The event goes onto the OpenTelemetry root span you already export. ClickHouse with HyperDX is a proven pairing (both open source).
|
|
14
|
+
- **Your log store.** The log sink emits one JSON event per request.
|
|
9
15
|
|
|
10
16
|
That format matters more now that agents build with you. Telemetry stops being something humans only glance at and becomes something software queries repeatedly: an agent can instrument a feature, deploy it, and verify it in production. One row per request is a dense way to feed production behavior back into a context window, and owning the storage avoids metered per-event and per-query observability fees.
|
|
11
17
|
|
|
@@ -41,9 +47,130 @@ grep '"main":true' log/development.log
|
|
|
41
47
|
|
|
42
48
|
One JSON object per request: route, status, timings, query counts, everything.
|
|
43
49
|
|
|
50
|
+
## Production in ten minutes: the bundled store
|
|
51
|
+
|
|
52
|
+
The gem ships its own telemetry database: a single Go process around DuckDB, deployed as a Kamal accessory on a VM you own. No collector, no OTel SDK, no vendor account. One `bin/rails generate` writes the accessory, `kamal setup` deploys it, and `kamal telemetry` opens a SQL prompt against production.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
bin/rails generate wide_events:store --host=203.0.113.20 --hostname=telemetry.example.com --retention-days=30
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--host` is the SSH address of the VM that will run the store. `--hostname` is the public DNS name the app and your SQL prompt reach it through; point an A record at the host now, so propagation happens while you finish the rest. The generator edits `config/deploy.yml` and writes two 64-hex tokens to `.kamal/wide-events-ingest-token` and `.kamal/wide-events-query-token` (mode 0600, gitignored, wired into `.kamal/secrets`). Every edit is marked and idempotent, so re-running it is safe. If your `config/deploy.yml` is shaped in a way the editor won't touch, it refuses and prints the complete block to merge by hand.
|
|
59
|
+
|
|
60
|
+
Here's the accessory it adds:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
accessories:
|
|
64
|
+
wide_events:
|
|
65
|
+
image: ghcr.io/adammiribyan/wide-events-store:0.2.1
|
|
66
|
+
host: 203.0.113.20
|
|
67
|
+
|
|
68
|
+
proxy:
|
|
69
|
+
host: telemetry.example.com
|
|
70
|
+
ssl: true
|
|
71
|
+
app_port: 7421
|
|
72
|
+
healthcheck:
|
|
73
|
+
path: /up
|
|
74
|
+
interval: 3
|
|
75
|
+
timeout: 3
|
|
76
|
+
|
|
77
|
+
env:
|
|
78
|
+
clear:
|
|
79
|
+
WIDE_EVENTS_LISTEN: 0.0.0.0:7421
|
|
80
|
+
WIDE_EVENTS_DATABASE: /var/lib/wide-events/events.duckdb
|
|
81
|
+
WIDE_EVENTS_SERVICE: myapp
|
|
82
|
+
WIDE_EVENTS_ENVIRONMENT: production
|
|
83
|
+
WIDE_EVENTS_RETENTION_DAYS: 30
|
|
84
|
+
secret:
|
|
85
|
+
- WIDE_EVENTS_INGEST_TOKEN
|
|
86
|
+
- WIDE_EVENTS_QUERY_TOKEN
|
|
87
|
+
|
|
88
|
+
directories:
|
|
89
|
+
- local: /var/lib/myapp-wide-events
|
|
90
|
+
remote: /var/lib/wide-events
|
|
91
|
+
mode: "0750"
|
|
92
|
+
owner: "1000:1000"
|
|
93
|
+
|
|
94
|
+
options:
|
|
95
|
+
cpus: 2
|
|
96
|
+
memory: 2g
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The structured `directories` entry is why the generator also raises `minimum_version` to `2.10.0`: older Kamal releases only accept a `local:remote` string and would mount the DuckDB volume without the mode and owner the container's uid/gid 1000 needs. On the app side it sets `WIDE_EVENTS_URL`, `WIDE_EVENTS_SERVICE`, and `WIDE_EVENTS_ENVIRONMENT` in `env.clear` and binds both tokens as secrets. Setting `WIDE_EVENTS_URL` is all it takes to switch the sink: the gem enables itself and sends to the store instead of an OTel span.
|
|
100
|
+
|
|
101
|
+
Then, in order:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bin/rails wide_events:setup:check # Kamal version, config, host directory, DNS, endpoint
|
|
105
|
+
kamal setup
|
|
106
|
+
bin/rails wide_events:setup:check # sends one synthetic event and queries it back
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`wide_events:setup:check` is the same command at every stage. It reports `failed`, `needs_dns`, `ready_to_deploy`, `degraded`, or `healthy`, and `needs_dns` is expected while an A record propagates: nothing is lost, run it again later. On a healthy store it writes one synthetic event, reads it back by id, and prints a route/p95 query, so you know ingest token, query token, TLS, and DuckDB all work before real traffic arrives.
|
|
110
|
+
|
|
111
|
+
Working with an agent? `bin/rails generate wide_events:skills` installs the `setting-up-wide-events-store` skill, which walks an agent through this whole flow, including what to say about DNS waits and backup limitations.
|
|
112
|
+
|
|
113
|
+
## Querying the store
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
kamal telemetry # interactive SQL prompt
|
|
117
|
+
echo "SELECT count(*) FROM wide_events" | kamal telemetry
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Output is a text table by default. `bin/rails wide_events:sql` reads `WIDE_EVENTS_FORMAT` (`table`, `json`, or `csv`) from the environment it runs in, so set it in the app container, not in your local shell.
|
|
121
|
+
|
|
122
|
+
`route`, `job_class`, `status`, `error`, `request_id`, `duration_ms`, `kind`, `deployment`, `source`, `occurred_at`, and `received_at` are real columns. Everything else the app set lives in an `attributes` VARIANT column, read as `attributes['key.name']` and cast to the type you want:
|
|
123
|
+
|
|
124
|
+
```sql
|
|
125
|
+
SELECT route,
|
|
126
|
+
count(*) AS requests,
|
|
127
|
+
quantile_cont(duration_ms, 0.5) AS p50_ms,
|
|
128
|
+
quantile_cont(duration_ms, 0.95) AS p95_ms
|
|
129
|
+
FROM wide_events
|
|
130
|
+
WHERE occurred_at > current_timestamp - INTERVAL 1 DAY
|
|
131
|
+
GROUP BY route
|
|
132
|
+
ORDER BY p95_ms DESC
|
|
133
|
+
LIMIT 15
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```sql
|
|
137
|
+
-- Exceptions that escaped without a named handled failure
|
|
138
|
+
SELECT route, attributes['exception.type']::VARCHAR AS exception_type, count(*) AS occurrences
|
|
139
|
+
FROM wide_events
|
|
140
|
+
WHERE error
|
|
141
|
+
AND coalesce(attributes['exception.slug']::VARCHAR, '') = ''
|
|
142
|
+
AND occurred_at > current_timestamp - INTERVAL 1 DAY
|
|
143
|
+
GROUP BY route, exception_type
|
|
144
|
+
ORDER BY occurrences DESC
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`SELECT * FROM wide_events_store_status` is the one-row operational view: rows in the last hour and day, oldest and newest event, database and free-disk bytes, queued ingest bytes, rejected batches, reported dropped events, write readiness and why, last checkpoint/retention/backup times and errors, and the store, protocol, and DuckDB versions. Read it first whenever a query returns nothing.
|
|
148
|
+
|
|
149
|
+
Queries are read-only and bounded: two run at a time with four more allowed to wait, 15 seconds of execution, 2 seconds of queueing, 10,000 rows, and 10 MiB of response. A statement that isn't a single `SELECT` is rejected by DuckDB's own parser before it runs.
|
|
150
|
+
|
|
151
|
+
## Two tokens, two jobs
|
|
152
|
+
|
|
153
|
+
The ingest token writes and the query token reads; the store refuses to start if they're equal. Sharing SQL access with a teammate or an agent means handing over `.kamal/wide-events-query-token` only, and never the ability to forge events. Both are 64 hex characters, generated once by the generator and reused on every later run.
|
|
154
|
+
|
|
155
|
+
## Sizing, retention, and what happens when the disk fills
|
|
156
|
+
|
|
157
|
+
One event is roughly 1 KB before compression. At 30-day retention, 100 requests/second is about 250 GB of raw rows before DuckDB's columnar compression, which typically lands 5-10x smaller; 10 requests/second fits comfortably in tens of gigabytes. Start with a 100 GB volume and 2 GB of RAM, watch `database_file_bytes` in the status view for a week, and resize from evidence.
|
|
158
|
+
|
|
159
|
+
Retention runs once a day and deletes events older than `WIDE_EVENTS_RETENTION_DAYS`. The store reserves the greater of 2 GiB or 10% of the volume as a write floor. Below it, ingest answers `507` and the sender holds its batches, but `/up` still returns 200 and queries keep working, so you can diagnose a full disk from the store itself rather than by SSH.
|
|
160
|
+
|
|
161
|
+
Loss is bounded and reported, never silent. Each app process queues up to 1,000 events or 8 MiB; past that, the oldest are dropped and the count travels with the next batch into `wide_events_loss_reports`, so a gap in the data is visible as a number. Batches are idempotent by `batch_id`, so a response lost after the store committed is retried and deduplicated instead of double-counted.
|
|
162
|
+
|
|
163
|
+
## Backups, upgrades, and rollback
|
|
164
|
+
|
|
165
|
+
A verified local backup runs daily and keeps the three newest copies in `backups/` next to the database. Take one on demand with `kamal wide-events-backup`, which prints the path it wrote.
|
|
166
|
+
|
|
167
|
+
Backups are local only. They protect against a bad upgrade or a mistaken `DELETE`, not against losing the VM or its volume; there's no off-host copy in v1. If that matters for your deployment, snapshot the host volume on your provider's schedule. Restoring is manual: stop the accessory, replace `/var/lib/wide-events/events.duckdb` with the backup file, and start it again. Nothing but the store process may open the database; the admin socket exists so backups happen through the running owner instead of a second process touching the file.
|
|
168
|
+
|
|
169
|
+
Upgrading is a gem bump. Each release publishes `ghcr.io/adammiribyan/wide-events-store:<version>` for linux/amd64 and linux/arm64. Re-run `bin/rails generate wide_events:store` with the same options to rewrite the pinned image, then `kamal accessory reboot wide_events` to pull it and restart. There is no `latest` tag on purpose, so an already-deployed accessory can't change under you on its next boot. Migrations are forward-only and run at startup: a store that finds a schema newer than it understands refuses to open rather than corrupting it. To roll back, pin the previous version by hand, and take a backup first if the newer release applied a migration.
|
|
170
|
+
|
|
44
171
|
## Production setup with OpenTelemetry
|
|
45
172
|
|
|
46
|
-
The
|
|
173
|
+
Prefer an existing OTel pipeline, or already run ClickHouse? Skip the store. The `:otel` sink writes the event onto the current OTel root span, which is the span OTel's Rack instrumentation opens at the top of the middleware stack. That means production needs an OpenTelemetry SDK configured; the gem detects it and wires itself after your initializers run. Minimal setup:
|
|
47
174
|
|
|
48
175
|
```ruby
|
|
49
176
|
# Gemfile
|
|
@@ -167,18 +294,22 @@ The teardown pattern makes the registry check work with parallel test workers: v
|
|
|
167
294
|
|
|
168
295
|
| Environment | Typical setup | Result |
|
|
169
296
|
|---|---|---|
|
|
297
|
+
| Production / staging | `WIDE_EVENTS_URL` set (the store generator sets it) | Enabled automatically, `:store` sink, batched over HTTPS |
|
|
170
298
|
| Production / staging | `OTEL_EXPORTER_OTLP_ENDPOINT` set | Enabled automatically, `:otel` sink, events on root spans |
|
|
171
299
|
| Development | `config.enabled = true`, `config.sink = :log` | One JSON line per request in the log |
|
|
172
300
|
| Test | Enabled via the test helper's capture, or strict mode for registry tracking | `capture_wide_events` collects them |
|
|
173
301
|
|
|
302
|
+
`WIDE_EVENTS_URL` wins over `OTEL_EXPORTER_OTLP_ENDPOINT` when both are set.
|
|
303
|
+
|
|
174
304
|
`WideEvent.install!` runs once at boot, only if enabled at that point. Flipping `config.enabled` at runtime gates the middleware and job hook (they check per request), but the span counter and subscribers only register at boot, so treat enablement as a boot-time decision.
|
|
175
305
|
|
|
176
306
|
## Built for the agent loop
|
|
177
307
|
|
|
178
|
-
`bin/rails generate wide_events:skills` installs
|
|
308
|
+
`bin/rails generate wide_events:skills` installs three agent skills:
|
|
179
309
|
|
|
180
310
|
- **instrumenting-wide-events**: the write path. Naming conventions, when to use `set` vs `phase` vs `error!`, the registry workflow, PII rules, test assertions.
|
|
181
|
-
- **debugging-with-wide-events**: the read path. Symptom-to-query workflow against ClickHouse/HyperDX or JSON logs, plus the standing queries worth running.
|
|
311
|
+
- **debugging-with-wide-events**: the read path. Symptom-to-query workflow against the DuckDB store, ClickHouse/HyperDX, or JSON logs, plus the standing queries worth running.
|
|
312
|
+
- **setting-up-wide-events-store**: the deploy path. Generator, DNS, `kamal setup`, and the checker states, end to end.
|
|
182
313
|
|
|
183
314
|
Skills land in `.claude/skills/`, which Claude Code and Claude-compatible agents read. Other agents (Cursor, etc.) read the pointer the install generator appends to `AGENTS.md`; the SKILL.md files are plain markdown, so copy or symlink them wherever your tooling looks (`.cursor/rules/`, docs, a system prompt).
|
|
184
315
|
|
|
@@ -186,8 +317,13 @@ Skills land in `.claude/skills/`, which Claude Code and Claude-compatible agents
|
|
|
186
317
|
|
|
187
318
|
```ruby
|
|
188
319
|
WideEvent.configure do |config|
|
|
189
|
-
config.enabled = ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].present? # default
|
|
190
|
-
config.sink = :
|
|
320
|
+
config.enabled = ENV["WIDE_EVENTS_URL"].present? || ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].present? # default
|
|
321
|
+
config.sink = :store # :store, :otel, :log, or any object responding to flush(attrs)
|
|
322
|
+
config.store_url = ENV["WIDE_EVENTS_URL"] # HTTPS, except an explicit loopback URL
|
|
323
|
+
config.store_service = ENV["WIDE_EVENTS_SERVICE"]
|
|
324
|
+
config.store_environment = ENV["WIDE_EVENTS_ENVIRONMENT"]
|
|
325
|
+
config.store_ingest_token = ENV["WIDE_EVENTS_INGEST_TOKEN"]
|
|
326
|
+
config.store_query_token = ENV["WIDE_EVENTS_QUERY_TOKEN"]
|
|
191
327
|
config.logger = nil # :log sink target; defaults to Rails.logger
|
|
192
328
|
config.strict = Rails.env.test? # track attributes against the registry
|
|
193
329
|
config.max_cache_attrs = 10
|
|
@@ -201,9 +337,11 @@ WideEvent.configure do |config|
|
|
|
201
337
|
end
|
|
202
338
|
```
|
|
203
339
|
|
|
340
|
+
Store sink notes: every store setting defaults to its `WIDE_EVENTS_*` environment variable, which the Kamal generator already sets, so most apps configure nothing here. Delivery happens on one background thread per process; application threads only append to a bounded in-memory queue and never block on the network. The sender is fork-aware, so Puma and Sidekiq workers each get their own queue instead of inheriting the parent's.
|
|
341
|
+
|
|
204
342
|
Log sink notes: events go to `config.logger` (or `Rails.logger`) at info level as single-line JSON. Point it at a dedicated logger (`config.logger = Logger.new("log/wide_events.log")`) to keep them out of your main log, and leave the sink off in the test environment unless you want one JSON line per test request; `capture_wide_events` is the intended test-side tap.
|
|
205
343
|
|
|
206
|
-
## Querying: ClickHouse + HyperDX
|
|
344
|
+
## Querying an OTLP backend: ClickHouse + HyperDX
|
|
207
345
|
|
|
208
346
|
[docs/clickhouse-hyperdx.md](https://github.com/adammiribyan/wide_events/blob/main/docs/clickhouse-hyperdx.md) has a local quickstart (one container) and a query cookbook. The flavor:
|
|
209
347
|
|
|
@@ -231,14 +369,17 @@ Flat keys, dot namespaces, snake_case leaves. Durations end in `_duration_ms`, c
|
|
|
231
369
|
bin/setup # bundle install + appraisal gemfiles
|
|
232
370
|
bundle exec rake test # run the suite
|
|
233
371
|
bin/rubocop # lint (rubocop-rails-omakase)
|
|
372
|
+
cd store && go test ./... # the DuckDB store service
|
|
234
373
|
```
|
|
235
374
|
|
|
236
|
-
The CI matrix runs the suite across Ruby 3.2 to 4.0 and Rails 7.1 to main via [Appraisal](https://github.com/thoughtbot/appraisal); run a specific combination locally with `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test`. Releases go out with `bin/release <version
|
|
375
|
+
The CI matrix runs the suite across Ruby 3.2 to 4.0 and Rails 7.1 to main via [Appraisal](https://github.com/thoughtbot/appraisal); run a specific combination locally with `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test`. [CONTRIBUTING.md](https://github.com/adammiribyan/wide_events/blob/main/CONTRIBUTING.md) covers the store's Go, Docker, cross-runtime integration, and Kamal compatibility commands. Releases go out with `bin/release <version>`, which also publishes the matching store image.
|
|
237
376
|
|
|
238
377
|
## Requirements
|
|
239
378
|
|
|
240
379
|
Ruby >= 3.2, Rails >= 7.1 (activesupport and rack are the only hard dependencies; opentelemetry-sdk and useragent are optional and detected at runtime).
|
|
241
380
|
|
|
381
|
+
The bundled store needs a Linux VM with Docker and Kamal >= 2.10.0. Images are published for linux/amd64 and linux/arm64.
|
|
382
|
+
|
|
242
383
|
## License
|
|
243
384
|
|
|
244
385
|
MIT.
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
require "rails/generators/base"
|
|
2
|
+
require "generators/wide_events/skill_installer"
|
|
2
3
|
|
|
3
4
|
module WideEvents
|
|
4
5
|
module Generators
|
|
5
6
|
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include WideEvents::Generators::SkillInstaller
|
|
8
|
+
|
|
6
9
|
source_root File.expand_path("templates", __dir__)
|
|
7
10
|
|
|
8
11
|
def create_initializer
|
|
@@ -13,10 +16,21 @@ module WideEvents
|
|
|
13
16
|
copy_file "registry.yml", "config/wide_event/registry.yml"
|
|
14
17
|
end
|
|
15
18
|
|
|
19
|
+
def install_skills
|
|
20
|
+
copy_wide_events_skills
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Idempotent: a second run of this generator must not duplicate the
|
|
24
|
+
# section in an AGENTS.md it already added it to.
|
|
16
25
|
def add_agents_section
|
|
17
26
|
section = File.read(File.expand_path("templates/agents_md_section.md", __dir__))
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
path = File.join(destination_root, "AGENTS.md")
|
|
28
|
+
if File.exist?(path)
|
|
29
|
+
if File.read(path).include?(section.strip)
|
|
30
|
+
say_status :identical, "AGENTS.md"
|
|
31
|
+
else
|
|
32
|
+
append_to_file "AGENTS.md", "\n#{section}"
|
|
33
|
+
end
|
|
20
34
|
else
|
|
21
35
|
create_file "AGENTS.md", section
|
|
22
36
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module WideEvents
|
|
2
|
+
module Generators
|
|
3
|
+
# Copies every bundled agent skill (skills/*/SKILL.md) into
|
|
4
|
+
# .claude/skills/<name>/SKILL.md. Shared by InstallGenerator (so a fresh
|
|
5
|
+
# `wide_events:install` sets up all three skills in one pass) and
|
|
6
|
+
# SkillsGenerator (a standalone way to add or refresh them later), so
|
|
7
|
+
# both stay byte-for-byte consistent and a new bundled skill only has to
|
|
8
|
+
# be wired up in one place.
|
|
9
|
+
#
|
|
10
|
+
# `copy_file` accepts an absolute source path regardless of the
|
|
11
|
+
# including generator's own `source_root`, so this mixes into either
|
|
12
|
+
# generator unchanged.
|
|
13
|
+
module SkillInstaller
|
|
14
|
+
SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
|
|
15
|
+
|
|
16
|
+
def copy_wide_events_skills
|
|
17
|
+
Dir[File.join(SKILLS_ROOT, "*", "SKILL.md")].sort.each do |path|
|
|
18
|
+
name = File.basename(File.dirname(path))
|
|
19
|
+
copy_file path, ".claude/skills/#{name}/SKILL.md"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
require "rails/generators/base"
|
|
2
|
+
require "generators/wide_events/skill_installer"
|
|
2
3
|
|
|
3
4
|
module WideEvents
|
|
4
5
|
module Generators
|
|
5
6
|
# Copies the agent skills shipped with the gem into .claude/skills/ so
|
|
6
|
-
# coding agents pick up the instrumentation and
|
|
7
|
+
# coding agents pick up the instrumentation, debugging, and store-setup
|
|
8
|
+
# conventions. A standalone way to add or refresh them later; a fresh
|
|
9
|
+
# `wide_events:install` already installs the same skills (see
|
|
10
|
+
# InstallGenerator), via the same WideEvents::Generators::SkillInstaller
|
|
11
|
+
# routine.
|
|
7
12
|
class SkillsGenerator < Rails::Generators::Base
|
|
8
|
-
|
|
13
|
+
include WideEvents::Generators::SkillInstaller
|
|
9
14
|
|
|
10
|
-
source_root SKILLS_ROOT
|
|
15
|
+
source_root SkillInstaller::SKILLS_ROOT
|
|
11
16
|
|
|
12
17
|
def copy_skills
|
|
13
|
-
|
|
14
|
-
name = File.basename(File.dirname(path))
|
|
15
|
-
copy_file File.join(name, "SKILL.md"), ".claude/skills/#{name}/SKILL.md"
|
|
16
|
-
end
|
|
18
|
+
copy_wide_events_skills
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "wide_event/version"
|
|
7
|
+
require "wide_event/kamal/deploy_editor"
|
|
8
|
+
require "wide_event/kamal/secrets_editor"
|
|
9
|
+
|
|
10
|
+
module WideEvents
|
|
11
|
+
module Generators
|
|
12
|
+
# Wires the bundled wide_events store container into a host app's Kamal
|
|
13
|
+
# deployment: an accessory in config/deploy.yml plus durable ingest/query
|
|
14
|
+
# tokens under .kamal/. Delegates every structural decision to
|
|
15
|
+
# WideEvent::Kamal::DeployEditor and WideEvent::Kamal::SecretsEditor so
|
|
16
|
+
# this class only handles option parsing, Thor plumbing, and applying
|
|
17
|
+
# both edits as a single transaction: nothing is written to disk unless
|
|
18
|
+
# every preflight check on both editors has already succeeded.
|
|
19
|
+
class StoreGenerator < Rails::Generators::Base
|
|
20
|
+
source_root File.expand_path("templates", __dir__)
|
|
21
|
+
|
|
22
|
+
DNS_NAME_RE = /\A(?=.{1,253}\z)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/
|
|
23
|
+
|
|
24
|
+
class_option :host, type: :string, desc: "SSH host/IP of the store VM (IP address or DNS name)"
|
|
25
|
+
class_option :hostname, type: :string, desc: "Public DNS hostname for the telemetry endpoint (no scheme/path)"
|
|
26
|
+
class_option :"retention-days", type: :string, default: "30", desc: "Event retention window, in days"
|
|
27
|
+
class_option :service, type: :string, desc: "Kamal service name (defaults to config/deploy.yml's service)"
|
|
28
|
+
class_option :environment, type: :string, default: "production", desc: "WIDE_EVENTS_ENVIRONMENT value"
|
|
29
|
+
|
|
30
|
+
def generate_accessory
|
|
31
|
+
host = options["host"]
|
|
32
|
+
hostname = options["hostname"]
|
|
33
|
+
retention_days = options["retention-days"]
|
|
34
|
+
|
|
35
|
+
return unless valid_options?(host, hostname, retention_days)
|
|
36
|
+
|
|
37
|
+
deploy_path = File.join(destination_root, "config/deploy.yml")
|
|
38
|
+
unless File.exist?(deploy_path)
|
|
39
|
+
say_status :error, "config/deploy.yml not found - run `kamal init` first, then re-run this generator", :red
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
service = options["service"] || default_service_name(deploy_path)
|
|
44
|
+
image = "ghcr.io/adammiribyan/wide-events-store:#{WideEvent::VERSION}"
|
|
45
|
+
|
|
46
|
+
deploy_result = WideEvent::Kamal::DeployEditor.new(
|
|
47
|
+
File.read(deploy_path),
|
|
48
|
+
service: service,
|
|
49
|
+
host: host,
|
|
50
|
+
hostname: hostname,
|
|
51
|
+
environment: options["environment"],
|
|
52
|
+
retention_days: retention_days.to_i,
|
|
53
|
+
image: image
|
|
54
|
+
).apply
|
|
55
|
+
|
|
56
|
+
if deploy_result.conflicts.any?
|
|
57
|
+
say_status :refused, "config/deploy.yml was left unchanged - merge this by hand:", :red
|
|
58
|
+
say deploy_result.snippet
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
secrets_result = WideEvent::Kamal::SecretsEditor.new(root: destination_root).apply
|
|
64
|
+
rescue WideEvent::Kamal::SecretsEditor::UnsafeTokenFile => e
|
|
65
|
+
say_status :error, e.message, :red
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if deploy_result.changed?
|
|
70
|
+
atomic_write(deploy_path, deploy_result.content)
|
|
71
|
+
say_status :update, "config/deploy.yml"
|
|
72
|
+
else
|
|
73
|
+
say_status :identical, "config/deploy.yml"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
secrets_result.write!
|
|
77
|
+
say_status :kamal, "wide_events store accessory ready - run `kamal setup` then `kamal telemetry`"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def valid_options?(host, hostname, retention_days)
|
|
83
|
+
unless present?(host) && valid_host?(host)
|
|
84
|
+
say_status :error, "--host must be an IP address or DNS name, got #{host.inspect}", :red
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
unless present?(hostname) && valid_hostname?(hostname)
|
|
89
|
+
say_status :error, "--hostname must be a bare DNS name without scheme or path, got #{hostname.inspect}", :red
|
|
90
|
+
return false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
unless positive_integer?(retention_days)
|
|
94
|
+
say_status :error, "--retention-days must be a positive integer, got #{retention_days.inspect}", :red
|
|
95
|
+
return false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def present?(value)
|
|
102
|
+
!value.nil? && !value.strip.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def valid_host?(value)
|
|
106
|
+
ip_address?(value) || DNS_NAME_RE.match?(value)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def valid_hostname?(value)
|
|
110
|
+
return false if value.include?("/") || value.include?(":") || value.include?("@")
|
|
111
|
+
|
|
112
|
+
DNS_NAME_RE.match?(value)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def ip_address?(value)
|
|
116
|
+
IPAddr.new(value)
|
|
117
|
+
true
|
|
118
|
+
rescue IPAddr::Error
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def positive_integer?(value)
|
|
123
|
+
value.to_s.match?(/\A[1-9][0-9]*\z/)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_service_name(deploy_path)
|
|
127
|
+
parsed = YAML.safe_load(File.read(deploy_path), permitted_classes: [], aliases: false)
|
|
128
|
+
service = parsed.is_a?(Hash) ? parsed["service"] : nil
|
|
129
|
+
service || File.basename(destination_root)
|
|
130
|
+
rescue Psych::Exception
|
|
131
|
+
File.basename(destination_root)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def atomic_write(path, content)
|
|
135
|
+
dir = File.dirname(path)
|
|
136
|
+
mode = File.exist?(path) ? File.stat(path).mode & 0o777 : 0o644
|
|
137
|
+
tmp = Tempfile.new(".wide_events_deploy", dir)
|
|
138
|
+
tmp.write(content)
|
|
139
|
+
tmp.close
|
|
140
|
+
File.chmod(mode, tmp.path)
|
|
141
|
+
FileUtils.mv(tmp.path, path)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
wide_events:
|
|
2
|
+
image: <%= image %>
|
|
3
|
+
host: <%= host %>
|
|
4
|
+
|
|
5
|
+
proxy:
|
|
6
|
+
host: <%= hostname %>
|
|
7
|
+
ssl: true
|
|
8
|
+
app_port: 7421
|
|
9
|
+
healthcheck:
|
|
10
|
+
path: /up
|
|
11
|
+
interval: 3
|
|
12
|
+
timeout: 3
|
|
13
|
+
|
|
14
|
+
env:
|
|
15
|
+
clear:
|
|
16
|
+
WIDE_EVENTS_LISTEN: 0.0.0.0:7421
|
|
17
|
+
WIDE_EVENTS_DATABASE: /var/lib/wide-events/events.duckdb
|
|
18
|
+
WIDE_EVENTS_SERVICE: <%= service %>
|
|
19
|
+
WIDE_EVENTS_ENVIRONMENT: <%= environment %>
|
|
20
|
+
WIDE_EVENTS_RETENTION_DAYS: <%= retention_days %>
|
|
21
|
+
secret:
|
|
22
|
+
- WIDE_EVENTS_INGEST_TOKEN
|
|
23
|
+
- WIDE_EVENTS_QUERY_TOKEN
|
|
24
|
+
|
|
25
|
+
directories:
|
|
26
|
+
- local: <%= local_directory %>
|
|
27
|
+
remote: /var/lib/wide-events
|
|
28
|
+
mode: "0750"
|
|
29
|
+
owner: "1000:1000"
|
|
30
|
+
|
|
31
|
+
options:
|
|
32
|
+
cpus: 2
|
|
33
|
+
memory: 2g
|
|
@@ -17,13 +17,26 @@ module WideEvent
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
attr_accessor :enabled, :max_cache_attrs, :span_scopes, :strict,
|
|
20
|
-
:logger, :error_handler, :scheduled_job_detector
|
|
20
|
+
:logger, :error_handler, :scheduled_job_detector,
|
|
21
|
+
:store_url, :store_service, :store_environment,
|
|
22
|
+
:store_ingest_token, :store_query_token
|
|
21
23
|
attr_reader :sink, :registry_path
|
|
22
24
|
|
|
25
|
+
# A resolved store sink is unusable until its configuration
|
|
26
|
+
# (url/service/environment/ingest token) is valid; falls back to this
|
|
27
|
+
# silent no-op so a misconfigured store never raises into a flush.
|
|
28
|
+
NOOP_STORE_SINK = Object.new.tap { |sink| sink.define_singleton_method(:flush) { |_attrs| nil } }.freeze
|
|
29
|
+
|
|
23
30
|
def initialize
|
|
24
|
-
@
|
|
25
|
-
@
|
|
31
|
+
@store_url = ENV["WIDE_EVENTS_URL"]
|
|
32
|
+
@enabled = @store_url.present? || ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].present?
|
|
33
|
+
@sink = @store_url.present? ? :store : :otel
|
|
26
34
|
@resolved_sink = nil
|
|
35
|
+
@store_service = ENV["WIDE_EVENTS_SERVICE"]
|
|
36
|
+
@store_environment = ENV["WIDE_EVENTS_ENVIRONMENT"]
|
|
37
|
+
@store_ingest_token = ENV["WIDE_EVENTS_INGEST_TOKEN"]
|
|
38
|
+
@store_query_token = ENV["WIDE_EVENTS_QUERY_TOKEN"]
|
|
39
|
+
@store_sender = nil
|
|
27
40
|
@max_cache_attrs = 10
|
|
28
41
|
@span_scopes = {
|
|
29
42
|
"OpenTelemetry::Instrumentation::PG" => "postgres_query",
|
|
@@ -46,10 +59,18 @@ module WideEvent
|
|
|
46
59
|
@resolved_sink ||= case @sink
|
|
47
60
|
when :otel then Sinks::OtelSpan.new
|
|
48
61
|
when :log then Sinks::LogLine.new(logger)
|
|
62
|
+
when :store then build_store_sink
|
|
49
63
|
else @sink
|
|
50
64
|
end
|
|
51
65
|
end
|
|
52
66
|
|
|
67
|
+
# True once #resolved_sink has actually built (or attempted to build) a
|
|
68
|
+
# sink; lets WideEvent.shutdown! avoid constructing one from scratch
|
|
69
|
+
# just to shut it down.
|
|
70
|
+
def resolved?
|
|
71
|
+
!@resolved_sink.nil?
|
|
72
|
+
end
|
|
73
|
+
|
|
53
74
|
def registry_path=(path)
|
|
54
75
|
@registry_path = path
|
|
55
76
|
@registry = nil
|
|
@@ -60,8 +81,48 @@ module WideEvent
|
|
|
60
81
|
@registry ||= Registry.load(resolved_registry_path)
|
|
61
82
|
end
|
|
62
83
|
|
|
84
|
+
# Stops the store sender's worker thread (if one was ever built) so
|
|
85
|
+
# tests and process shutdown never leak the background thread.
|
|
86
|
+
def shutdown_store_sender!(timeout: WideEvent::Store::Sender::SHUTDOWN_TIMEOUT)
|
|
87
|
+
@store_sender&.shutdown(timeout: timeout)
|
|
88
|
+
@store_sender = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
63
91
|
private
|
|
64
92
|
|
|
93
|
+
def build_store_sink
|
|
94
|
+
# A prior resolution (e.g. `config.sink = :store` twice, or
|
|
95
|
+
# re-assigning store_* fields and re-resolving) may have already
|
|
96
|
+
# built and started a sender; shut it down before replacing it so
|
|
97
|
+
# its worker thread and queue don't strand themselves, ownerless and
|
|
98
|
+
# retrying forever.
|
|
99
|
+
shutdown_store_sender!
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "WIDE_EVENTS_SERVICE is required for the store sink" if @store_service.to_s.strip.empty?
|
|
102
|
+
raise ArgumentError, "WIDE_EVENTS_ENVIRONMENT is required for the store sink" if @store_environment.to_s.strip.empty?
|
|
103
|
+
# Client itself no longer validates token presence at construction
|
|
104
|
+
# (a query-only Client has no ingest token to give it) — the ingest
|
|
105
|
+
# path validates this here, at sink-resolution time, instead.
|
|
106
|
+
raise ArgumentError, "WIDE_EVENTS_INGEST_TOKEN is required for the store sink" if @store_ingest_token.to_s.strip.empty?
|
|
107
|
+
|
|
108
|
+
client = WideEvent::Store::Client.new(url: @store_url, ingest_token: @store_ingest_token)
|
|
109
|
+
@store_sender = WideEvent::Store::Sender.new(client: client, service: @store_service, environment: @store_environment)
|
|
110
|
+
Sinks::Store.new(sender: @store_sender)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
# This instance's own handler, not the module-level WideEvent.handle_error
|
|
113
|
+
# funnel: build_store_sink runs on whatever Configuration instance is
|
|
114
|
+
# resolving (in production that's always WideEvent.config, but tests
|
|
115
|
+
# also resolve standalone instances directly).
|
|
116
|
+
report_configuration_error(e)
|
|
117
|
+
NOOP_STORE_SINK
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def report_configuration_error(exception)
|
|
121
|
+
error_handler&.call(exception, "store_sink_configuration")
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
65
126
|
# `registry_path` stays relative so the generated doc can commit it;
|
|
66
127
|
# absolute paths win inside Pathname#join and pass through as-is.
|
|
67
128
|
def resolved_registry_path
|