robot_lab-audit 0.2.6
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 +7 -0
- data/.envrc +3 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +2 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +37 -0
- data/CLAUDE.md +60 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +141 -0
- data/Rakefile +132 -0
- data/docs/getting_started.md +129 -0
- data/docs/how_it_works.md +193 -0
- data/docs/index.md +44 -0
- data/docs/querying.md +126 -0
- data/examples/01_basic_usage.rb +294 -0
- data/examples/common.rb +54 -0
- data/lib/robot_lab/audit/event_log.rb +102 -0
- data/lib/robot_lab/audit/hook.rb +146 -0
- data/lib/robot_lab/audit/version.rb +7 -0
- data/lib/robot_lab/audit.rb +31 -0
- data/mkdocs.yml +117 -0
- data/sig/robot_lab/audit.rbs +6 -0
- metadata +96 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Getting Started
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Ruby 3.2+ (per the gemspec's `required_ruby_version`)
|
|
6
|
+
- `robot_lab` — `robot_lab/audit` requires `RobotLab::Hook` to already be defined, so `robot_lab` must be `require`d first.
|
|
7
|
+
- SQLite — the gem depends on the `sqlite3` gem directly; no separate database server to run.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add to your `Gemfile`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "robot_lab"
|
|
15
|
+
gem "robot_lab-audit"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or install directly:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
gem install robot_lab-audit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Enabling Globally
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require "robot_lab"
|
|
34
|
+
require "robot_lab/audit"
|
|
35
|
+
|
|
36
|
+
RobotLab::Audit.enable(db_path: "~/.robot_lab/audit.db")
|
|
37
|
+
|
|
38
|
+
network = RobotLab.create_network(...)
|
|
39
|
+
network.run(message: "do something")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`RobotLab::Audit.enable(db_path:)`:
|
|
43
|
+
|
|
44
|
+
1. Instantiates `RobotLab::Audit::EventLog`, which opens (or creates) the SQLite
|
|
45
|
+
file at `db_path`, creating any missing parent directories along the way.
|
|
46
|
+
2. Assigns the log to `RobotLab::Audit::Hook.event_log`.
|
|
47
|
+
3. Registers the hook globally via `RobotLab.on(Hook)`.
|
|
48
|
+
|
|
49
|
+
Calling `enable` again with a new `db_path` simply repoints the hook at a new
|
|
50
|
+
`EventLog` — it is safe to call once at boot.
|
|
51
|
+
|
|
52
|
+
If `robot_lab` has not been loaded yet, `enable` raises
|
|
53
|
+
`RobotLab::Audit::Error` ("robot_lab must be loaded before calling
|
|
54
|
+
RobotLab::Audit.enable").
|
|
55
|
+
|
|
56
|
+
## What Gets Logged
|
|
57
|
+
|
|
58
|
+
| Table | One row per | Key columns |
|
|
59
|
+
|-------|--------------|-------------|
|
|
60
|
+
| `network_runs` | `Network#run` call | `run_id`, `network_name`, `input`, `result`, `error_class`, `error_message`, `started_at`, `finished_at`, `duration_ms` |
|
|
61
|
+
| `audit_events` | `Robot#run` or `Tool#call` | `run_id`, `event_type`, `robot_name`, `tool_name`, `input`, `output`, `error_class`, `error_message`, `started_at`, `finished_at`, `duration_ms` |
|
|
62
|
+
|
|
63
|
+
`event_type` is `"robot_run"` or `"tool_call"`. All `input`/`output`/`result`
|
|
64
|
+
values are stored as JSON text. Timestamps are UTC, ISO 8601, with millisecond
|
|
65
|
+
precision.
|
|
66
|
+
|
|
67
|
+
Every event produced while a `Network#run` is in flight shares that run's
|
|
68
|
+
`run_id`, so you can pull the entire trace for one execution with
|
|
69
|
+
`events_for(run_id)` (see [Querying the Log](querying.md)).
|
|
70
|
+
|
|
71
|
+
Standalone `Robot#run` calls made outside of a network are recorded too —
|
|
72
|
+
their `run_id` column is `nil`.
|
|
73
|
+
|
|
74
|
+
## Minimal Example
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
require "robot_lab"
|
|
78
|
+
require "robot_lab/audit"
|
|
79
|
+
require "tmpdir"
|
|
80
|
+
|
|
81
|
+
RobotLab::Audit.enable(db_path: File.join(Dir.mktmpdir, "audit.db"))
|
|
82
|
+
|
|
83
|
+
robot = RobotLab.build(name: "greeter", system_prompt: "You are a friendly greeter.")
|
|
84
|
+
robot.run("Say hello")
|
|
85
|
+
|
|
86
|
+
log = RobotLab::Audit::Hook.event_log
|
|
87
|
+
log.events_for(nil).each do |event|
|
|
88
|
+
puts "#{event[:event_type]} #{event[:robot_name]} #{event[:duration_ms]}ms"
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The gem ships a fuller, self-contained demo with no API keys required — it
|
|
93
|
+
uses deterministic stubbed robots and tools so the audit trail is
|
|
94
|
+
reproducible:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
bundle exec ruby examples/01_basic_usage.rb
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
It walks through five scenarios: enabling the hook, a two-robot network run,
|
|
101
|
+
tool call capture, error capture, and scoping the hook to a single robot.
|
|
102
|
+
|
|
103
|
+
## Scoping to a Robot or Network
|
|
104
|
+
|
|
105
|
+
`RobotLab::Audit.enable` registers the hook globally — every robot and
|
|
106
|
+
network in the process gets logged. To audit only specific objects, skip
|
|
107
|
+
`enable` and register the hook directly instead:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
RobotLab::Audit::Hook.event_log = RobotLab::Audit::EventLog.new(db_path: "audit.db")
|
|
111
|
+
|
|
112
|
+
robot.on(RobotLab::Audit::Hook)
|
|
113
|
+
# or
|
|
114
|
+
network.on(RobotLab::Audit::Hook)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Robots and networks that never call `.on(RobotLab::Audit::Hook)` produce no
|
|
118
|
+
audit entries at all — the hook simply never fires for them.
|
|
119
|
+
|
|
120
|
+
If you had previously called `RobotLab::Audit.enable`, clear the global hook
|
|
121
|
+
registry first (`RobotLab.hooks.clear`) so the same events aren't logged
|
|
122
|
+
twice — once globally and once for the specific object.
|
|
123
|
+
|
|
124
|
+
## Key Constraints
|
|
125
|
+
|
|
126
|
+
- `robot_lab` must be `require`d before `robot_lab/audit` — see Prerequisites above.
|
|
127
|
+
- `EventLog` is not thread-safe by itself; SQLite handles concurrent writes via its own file locking.
|
|
128
|
+
- Run IDs are correlated via `Thread.current` — they are **not** propagated across Fibers or Ractors. Work dispatched off-thread (e.g. via `robot_lab-ractor`) will not share a `run_id` with its parent network run.
|
|
129
|
+
- `safe_json` (used internally to serialise `input`/`output`) silently falls back to `#inspect` for values that can't be JSON-encoded — don't assume every `input`/`output` column is valid JSON when a tool passes an unusual argument type.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# How It Works
|
|
2
|
+
|
|
3
|
+
## Schema
|
|
4
|
+
|
|
5
|
+
`EventLog#initialize` opens the SQLite file at `db_path` (expanding `~` and
|
|
6
|
+
creating any missing parent directories via `FileUtils.mkdir_p`), then runs a
|
|
7
|
+
`CREATE TABLE IF NOT EXISTS` schema batch — safe to call every time the
|
|
8
|
+
process boots.
|
|
9
|
+
|
|
10
|
+
```sql
|
|
11
|
+
CREATE TABLE network_runs (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
run_id TEXT NOT NULL UNIQUE,
|
|
14
|
+
network_name TEXT,
|
|
15
|
+
input TEXT,
|
|
16
|
+
result TEXT,
|
|
17
|
+
error_class TEXT,
|
|
18
|
+
error_message TEXT,
|
|
19
|
+
started_at TEXT NOT NULL,
|
|
20
|
+
finished_at TEXT,
|
|
21
|
+
duration_ms REAL
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE audit_events (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
run_id TEXT,
|
|
27
|
+
event_type TEXT NOT NULL,
|
|
28
|
+
robot_name TEXT,
|
|
29
|
+
tool_name TEXT,
|
|
30
|
+
input TEXT,
|
|
31
|
+
output TEXT,
|
|
32
|
+
error_class TEXT,
|
|
33
|
+
error_message TEXT,
|
|
34
|
+
started_at TEXT NOT NULL,
|
|
35
|
+
finished_at TEXT,
|
|
36
|
+
duration_ms REAL
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Two tables, no foreign key constraint between them — `audit_events.run_id`
|
|
41
|
+
is simply a shared correlation string, nullable for events that happen
|
|
42
|
+
outside of any network run.
|
|
43
|
+
|
|
44
|
+
| Column | Present on | Notes |
|
|
45
|
+
|--------|-----------|-------|
|
|
46
|
+
| `run_id` | both | `NOT NULL UNIQUE` on `network_runs` (one row per run); nullable, non-unique on `audit_events` (many rows share a run) |
|
|
47
|
+
| `event_type` | `audit_events` only | `"robot_run"` or `"tool_call"` |
|
|
48
|
+
| `robot_name` / `tool_name` | `audit_events` only | `tool_name` is `nil` for `"robot_run"` events |
|
|
49
|
+
| `network_name` | `network_runs` only | taken from `ctx.network&.name` |
|
|
50
|
+
| `input` / `output` / `result` | both | JSON-encoded text (see [JSON Serialisation](#json-serialisation-safe_json) below) |
|
|
51
|
+
| `error_class` / `error_message` | both | populated from the exception object when the call raised, else `nil` |
|
|
52
|
+
| `started_at` / `finished_at` | both | UTC, ISO 8601, millisecond precision (`Time#iso8601(3)`) |
|
|
53
|
+
| `duration_ms` | both | `(finished - started) * 1000`, rounded to 2 decimal places |
|
|
54
|
+
|
|
55
|
+
## Hook Callback Mapping
|
|
56
|
+
|
|
57
|
+
`RobotLab::Audit::Hook` is a subclass of `RobotLab::Hook` with
|
|
58
|
+
`self.namespace = :audit`. It implements three before/after callback pairs
|
|
59
|
+
from RobotLab's [Hook system](https://github.com/MadBomber/robot_lab/blob/main/docs/guides/hooks.md):
|
|
60
|
+
|
|
61
|
+
| Hook family | Callbacks | Writes |
|
|
62
|
+
|-------------|-----------|--------|
|
|
63
|
+
| `:network_run` | `before_network_run`, `after_network_run` | one `network_runs` row |
|
|
64
|
+
| `:run` | `before_run`, `after_run` | one `audit_events` row, `event_type: "robot_run"` |
|
|
65
|
+
| `:tool_call` | `before_tool_call`, `after_tool_call` | one `audit_events` row, `event_type: "tool_call"` |
|
|
66
|
+
|
|
67
|
+
`before_network_run`, `after_network_run`, `after_run`, and `after_tool_call`
|
|
68
|
+
all `return unless event_log` (or `event_log && current_run_id`) as their
|
|
69
|
+
first line — they are true no-ops when `Hook.event_log` is `nil`. This is what
|
|
70
|
+
makes it safe to `require "robot_lab/audit"` without calling `enable`, and
|
|
71
|
+
what makes per-robot/per-network scoping work cleanly.
|
|
72
|
+
|
|
73
|
+
`before_run` and `before_tool_call` are a partial exception: they run
|
|
74
|
+
unconditionally, stashing `Time.now.utc` on `ctx.local.started_at` even when
|
|
75
|
+
`event_log` is `nil`. That write has no observable effect on its own — it's
|
|
76
|
+
local, namespace-scoped state (see below) that only gets read if the matching
|
|
77
|
+
`after_*` callback goes on to record a row, and that callback still checks
|
|
78
|
+
`event_log` before touching the database. So the *net* behavior is the same
|
|
79
|
+
(nothing is written when auditing is off), it's just the `after_*` half of
|
|
80
|
+
each pair that carries the guard, not the `before_*` half.
|
|
81
|
+
|
|
82
|
+
### Network run lifecycle
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
before_network_run(ctx)
|
|
86
|
+
→ generates a new run_id (SecureRandom.uuid)
|
|
87
|
+
→ stores run_id and started_at in Thread.current
|
|
88
|
+
|
|
89
|
+
... network executes: robots run, tools are called ...
|
|
90
|
+
|
|
91
|
+
after_network_run(ctx)
|
|
92
|
+
→ writes the network_runs row using the thread-local run_id/started_at
|
|
93
|
+
→ clears both thread-locals in an `ensure` block
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`after_network_run` is a no-op if there is no `current_run_id` — this
|
|
97
|
+
guards against `after_network_run` firing without a matching
|
|
98
|
+
`before_network_run` (for example, if the hook was registered mid-flight).
|
|
99
|
+
|
|
100
|
+
### Robot run and tool call lifecycle
|
|
101
|
+
|
|
102
|
+
`before_run` / `before_tool_call` don't touch `Thread.current` at all —
|
|
103
|
+
they stash `started_at` on `ctx.local`, which is namespace-scoped state that
|
|
104
|
+
RobotLab's `HookContext` provides per-callback-pair (see
|
|
105
|
+
`ctx.with_namespace(:audit)` in the test suite for how this is activated).
|
|
106
|
+
|
|
107
|
+
`after_run` / `after_tool_call` then read `ctx.local.started_at`, look up the
|
|
108
|
+
thread-local `run_id` set by an enclosing `before_network_run` (or `nil` if
|
|
109
|
+
there is none), and write one `audit_events` row via the shared private
|
|
110
|
+
`record_audit_event` helper.
|
|
111
|
+
|
|
112
|
+
### Run ID correlation
|
|
113
|
+
|
|
114
|
+
All events produced while a given `Network#run` is executing — the network
|
|
115
|
+
run itself, every robot invocation, every tool call — share the same
|
|
116
|
+
`run_id`, because that ID lives in `Thread.current` for the duration of the
|
|
117
|
+
network run and every robot/tool callback reads it. This is what makes
|
|
118
|
+
`EventLog#events_for(run_id)` a full execution trace rather than a fragment.
|
|
119
|
+
|
|
120
|
+
Standalone `Robot#run` or `Tool#call` invocations made outside of any
|
|
121
|
+
network run see `current_run_id` as `nil` — they still get logged, just
|
|
122
|
+
without a correlating run.
|
|
123
|
+
|
|
124
|
+
## Thread-Local State
|
|
125
|
+
|
|
126
|
+
Two `Thread.current` keys carry state between the `before_*` and `after_*`
|
|
127
|
+
halves of the network-run lifecycle:
|
|
128
|
+
|
|
129
|
+
| Key | Set in | Cleared in | Purpose |
|
|
130
|
+
|-----|--------|-----------|---------|
|
|
131
|
+
| `:robot_lab_audit_run_id` | `before_network_run` | `after_network_run` (`ensure`) | correlates all events within one network run |
|
|
132
|
+
| `:robot_lab_audit_network_started_at` | `before_network_run` | `after_network_run` (`ensure`) | computes the network run's `duration_ms` |
|
|
133
|
+
|
|
134
|
+
Because this is `Thread.current` rather than `ctx.local`, run correlation
|
|
135
|
+
does **not** cross Fiber or Ractor boundaries — parallel work dispatched via
|
|
136
|
+
`robot_lab-ractor`, for instance, will not inherit the parent network run's
|
|
137
|
+
`run_id`. `before_run`/`before_tool_call` state, by contrast, uses `ctx.local`
|
|
138
|
+
and is scoped per-callback-pair regardless of thread.
|
|
139
|
+
|
|
140
|
+
## JSON Serialisation (`safe_json`)
|
|
141
|
+
|
|
142
|
+
`input`/`output` values (tool arguments, robot request/response payloads) are
|
|
143
|
+
serialised with a private `safe_json` helper:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
def safe_json(value)
|
|
147
|
+
return nil if value.nil?
|
|
148
|
+
|
|
149
|
+
JSON.generate(value)
|
|
150
|
+
rescue JSON::GeneratorError, TypeError
|
|
151
|
+
value.inspect
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
If a value can't be JSON-encoded (circular references, unsupported types),
|
|
156
|
+
the column falls back to Ruby's `#inspect` string instead of raising. Don't
|
|
157
|
+
assume every `input`/`output` value is parseable JSON — check for `nil`
|
|
158
|
+
first if you write tooling against the column, and be prepared for an
|
|
159
|
+
`inspect`-style string on the rare unencodable payload.
|
|
160
|
+
|
|
161
|
+
`network_runs.input` and `.result` use dedicated extractors
|
|
162
|
+
(`extract_network_input`, `extract_network_result`) that look for a
|
|
163
|
+
`:message`/`'message'` key on the network context, or — for the result — a
|
|
164
|
+
`.reply` on `result.value` when the result object responds to `:value` (the
|
|
165
|
+
shape `simple_flow`'s result wrapper returns), falling back to `safe_json`
|
|
166
|
+
and finally to `#inspect` if anything raises.
|
|
167
|
+
|
|
168
|
+
## Error Capture
|
|
169
|
+
|
|
170
|
+
When a network run, robot run, or tool call raises, the hook's
|
|
171
|
+
`error_fields` helper extracts `error_class` (the exception's class name as
|
|
172
|
+
a string) and `error_message` (the exception's message) from whatever
|
|
173
|
+
`ctx.error` / `ctx.tool_error` object RobotLab's Hook system populates. Both
|
|
174
|
+
columns are `nil` on success. `EventLog#recent_errors` filters
|
|
175
|
+
`audit_events` down to rows where `error_class IS NOT NULL`.
|
|
176
|
+
|
|
177
|
+
## RobotLab Extension Registration
|
|
178
|
+
|
|
179
|
+
`lib/robot_lab/audit.rb` ends with:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
require_relative 'audit/hook' if defined?(RobotLab::Hook)
|
|
183
|
+
|
|
184
|
+
if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
|
|
185
|
+
RobotLab.register_extension(:audit, RobotLab::Audit)
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The `Hook` subclass is only loaded when `RobotLab::Hook` is already defined,
|
|
190
|
+
and extension registration only runs when `RobotLab.register_extension`
|
|
191
|
+
exists — both guards make `require "robot_lab/audit"` safe even in contexts
|
|
192
|
+
where `robot_lab` hasn't been (fully) loaded, though calling `enable` in that
|
|
193
|
+
state still raises (see [Getting Started](getting_started.md)).
|
data/docs/index.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# robot_lab-audit
|
|
2
|
+
|
|
3
|
+
A SQLite-backed execution audit log for the [RobotLab](https://github.com/MadBomber/robot_lab) LLM agent framework.
|
|
4
|
+
|
|
5
|
+
`RobotLab::Audit` plugs into RobotLab's [Hook system](https://github.com/MadBomber/robot_lab/blob/main/docs/guides/hooks.md) and writes one row per network run, robot invocation, and tool call to a local SQLite database — timestamps, JSON input/output, errors, and duration in milliseconds. Zero changes required to your robots, networks, or tools.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "robot_lab"
|
|
9
|
+
require "robot_lab/audit"
|
|
10
|
+
|
|
11
|
+
RobotLab::Audit.enable(db_path: "~/.robot_lab/audit.db")
|
|
12
|
+
|
|
13
|
+
# All subsequent network and robot runs are recorded automatically.
|
|
14
|
+
network = RobotLab.create_network(...)
|
|
15
|
+
network.run(message: "do something")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Every event from a single `Network#run` shares the same `run_id`, so the full
|
|
19
|
+
execution trace — which robots ran, which tools they called, what each one
|
|
20
|
+
returned, and how long it took — can be reconstructed after the fact for
|
|
21
|
+
post-mortem analysis or debugging.
|
|
22
|
+
|
|
23
|
+
## Navigation
|
|
24
|
+
|
|
25
|
+
- [Getting Started](getting_started.md) — installation, enabling the hook, a minimal example, scoping to one robot or network
|
|
26
|
+
- [How It Works](how_it_works.md) — the SQLite schema, how each Hook callback maps to a row, run_id correlation, thread-local state, JSON serialisation
|
|
27
|
+
- [Querying the Log](querying.md) — the `EventLog` query API and raw SQL examples for post-mortem analysis
|
|
28
|
+
|
|
29
|
+
## At a Glance
|
|
30
|
+
|
|
31
|
+
| | |
|
|
32
|
+
|---|---|
|
|
33
|
+
| **Storage** | SQLite (via the `sqlite3` gem) — one file, no server |
|
|
34
|
+
| **Tables** | `network_runs`, `audit_events` |
|
|
35
|
+
| **Wiring** | `RobotLab::Hook` subclass (`RobotLab::Audit::Hook`), namespace `:audit` |
|
|
36
|
+
| **Enable globally** | `RobotLab::Audit.enable(db_path:)` |
|
|
37
|
+
| **Enable per robot/network** | `robot.on(RobotLab::Audit::Hook)` / `network.on(RobotLab::Audit::Hook)` |
|
|
38
|
+
| **Query API** | `EventLog#network_runs`, `#events_for(run_id)`, `#recent_errors` |
|
|
39
|
+
|
|
40
|
+
## Links
|
|
41
|
+
|
|
42
|
+
- [RobotLab Core](https://github.com/MadBomber/robot_lab)
|
|
43
|
+
- [RubyGems](https://rubygems.org/gems/robot_lab-audit)
|
|
44
|
+
- [GitHub](https://github.com/MadBomber/robot_lab-audit)
|
data/docs/querying.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Querying the Log
|
|
2
|
+
|
|
3
|
+
`RobotLab::Audit::EventLog` is the only public query surface. Get a handle
|
|
4
|
+
to the active log via the hook (when using global `enable`):
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
log = RobotLab::Audit::Hook.event_log
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
or hold onto the `EventLog` instance yourself if you constructed it directly
|
|
11
|
+
for scoped auditing (see [Getting Started — Scoping](getting_started.md#scoping-to-a-robot-or-network)).
|
|
12
|
+
|
|
13
|
+
Each query method returns an `Array` of `Hash` objects keyed by column name
|
|
14
|
+
as symbols (e.g. `row[:run_id]`, `row[:duration_ms]`).
|
|
15
|
+
|
|
16
|
+
## `network_runs(limit: 100)`
|
|
17
|
+
|
|
18
|
+
Recent network runs, newest first (ordered by `started_at DESC`).
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
log.network_runs(limit: 20).each do |run|
|
|
22
|
+
status = run[:error_class] ? "FAILED (#{run[:error_class]})" : "OK"
|
|
23
|
+
puts "#{run[:run_id]} #{run[:network_name]} #{status} #{run[:duration_ms]}ms"
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Columns returned: `id`, `run_id`, `network_name`, `input`, `result`,
|
|
28
|
+
`error_class`, `error_message`, `started_at`, `finished_at`, `duration_ms`.
|
|
29
|
+
|
|
30
|
+
## `events_for(run_id)`
|
|
31
|
+
|
|
32
|
+
Every `audit_events` row for one network run, ordered chronologically
|
|
33
|
+
(`ORDER BY started_at`) — the full execution trace for that run: every robot
|
|
34
|
+
invocation and every tool call, interleaved in the order they actually
|
|
35
|
+
happened.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
run_id = log.network_runs.first[:run_id]
|
|
39
|
+
|
|
40
|
+
log.events_for(run_id).each do |event|
|
|
41
|
+
label = event[:tool_name] || event[:robot_name]
|
|
42
|
+
puts "#{event[:event_type].ljust(10)} #{label.ljust(20)} #{event[:duration_ms]}ms"
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Columns returned: `id`, `run_id`, `event_type`, `robot_name`, `tool_name`,
|
|
47
|
+
`input`, `output`, `error_class`, `error_message`, `started_at`,
|
|
48
|
+
`finished_at`, `duration_ms`.
|
|
49
|
+
|
|
50
|
+
Standalone `Robot#run`/`Tool#call` invocations made outside any network run
|
|
51
|
+
are recorded with `run_id: nil` — pass `nil` to `events_for` to retrieve
|
|
52
|
+
them:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
log.events_for(nil)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## `recent_errors(limit: 50)`
|
|
59
|
+
|
|
60
|
+
`audit_events` rows where `error_class IS NOT NULL`, newest first — every
|
|
61
|
+
failed robot run or tool call across all network runs (and standalone
|
|
62
|
+
calls), regardless of `run_id`.
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
log.recent_errors.each do |event|
|
|
66
|
+
puts "#{event[:started_at]} #{event[:robot_name]}/#{event[:tool_name]} " \
|
|
67
|
+
"#{event[:error_class]}: #{event[:error_message]}"
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This is the fastest way to answer "what broke recently?" without knowing a
|
|
72
|
+
specific `run_id` up front.
|
|
73
|
+
|
|
74
|
+
## Querying with Raw SQL
|
|
75
|
+
|
|
76
|
+
`EventLog` doesn't try to be a full reporting layer — for anything beyond
|
|
77
|
+
the three methods above, query the SQLite file directly. It's a plain
|
|
78
|
+
single-file database; any SQL client works.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
sqlite3 ~/.robot_lab/audit.db \
|
|
82
|
+
"SELECT event_type, robot_name, tool_name, duration_ms, error_class
|
|
83
|
+
FROM audit_events
|
|
84
|
+
WHERE run_id = 'some-run-id'
|
|
85
|
+
ORDER BY started_at;"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Useful ad hoc queries:
|
|
89
|
+
|
|
90
|
+
```sql
|
|
91
|
+
-- Slowest tool calls across all runs
|
|
92
|
+
SELECT tool_name, robot_name, duration_ms, started_at
|
|
93
|
+
FROM audit_events
|
|
94
|
+
WHERE event_type = 'tool_call'
|
|
95
|
+
ORDER BY duration_ms DESC
|
|
96
|
+
LIMIT 20;
|
|
97
|
+
|
|
98
|
+
-- Error rate by robot
|
|
99
|
+
SELECT robot_name,
|
|
100
|
+
COUNT(*) AS total,
|
|
101
|
+
SUM(CASE WHEN error_class IS NOT NULL THEN 1 ELSE 0 END) AS errors
|
|
102
|
+
FROM audit_events
|
|
103
|
+
WHERE event_type = 'robot_run'
|
|
104
|
+
GROUP BY robot_name;
|
|
105
|
+
|
|
106
|
+
-- Network runs that failed
|
|
107
|
+
SELECT run_id, network_name, error_class, error_message, started_at
|
|
108
|
+
FROM network_runs
|
|
109
|
+
WHERE error_class IS NOT NULL
|
|
110
|
+
ORDER BY started_at DESC;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Because `input`/`output`/`result` are stored as JSON text, SQLite's
|
|
114
|
+
[JSON1 functions](https://www.sqlite.org/json1.html) (`json_extract`, etc.)
|
|
115
|
+
can reach into them directly:
|
|
116
|
+
|
|
117
|
+
```sql
|
|
118
|
+
SELECT json_extract(input, '$.text') AS text_arg, duration_ms
|
|
119
|
+
FROM audit_events
|
|
120
|
+
WHERE tool_name = 'word_count_tool';
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Remember that a small fraction of `input`/`output` values may be a
|
|
124
|
+
`#inspect` fallback string rather than valid JSON (see
|
|
125
|
+
[How It Works — JSON Serialisation](how_it_works.md#json-serialisation-safe_json)) — `json_extract` will
|
|
126
|
+
simply return `NULL` for those rows rather than raising.
|