Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9fc9af1
feat(dgw): search session logs across recordings
irvingoujAtDevolution Oct 19, 2026
ce252c8
fix(dgw): measure search log lines like the log viewer
irvingoujAtDevolution Oct 19, 2026
0474064
fix(dgw): inline "all" in the recording selection schema
irvingoujAtDevolution Oct 19, 2026
9d0e9c8
fix(dgw): read at most 16 MiB of logs per search
irvingoujAtDevolution Oct 20, 2026
9fcadd6
feat(dgw): search the raw text of slog artifacts
irvingoujAtDevolution Oct 21, 2026
632ee1f
docs(dgw): state the recording search intent
irvingoujAtDevolution Oct 21, 2026
7d69f98
fix(dgw): list the start time range in the OpenAPI schemas
irvingoujAtDevolution Oct 21, 2026
ce74751
fix(dgw): match the search query as sent and fix limit edges
irvingoujAtDevolution Oct 21, 2026
27db4ed
fix(dgw): fold the case of a sigma and search each log once
irvingoujAtDevolution Oct 21, 2026
f35fc7f
fix(dgw): list each recording once and lowercase lines quickly
irvingoujAtDevolution Oct 21, 2026
56c3533
feat(dgw): reject unknown fields in a recording search
irvingoujAtDevolution Oct 21, 2026
8acff8f
feat(dgw): search the string values of JSON log lines
irvingoujAtDevolution Oct 21, 2026
2b93139
docs(dgw): state the value matching rule in the search intent
irvingoujAtDevolution Oct 21, 2026
862d068
test(dgw): check unescaping, arrays and null in recording search
irvingoujAtDevolution Oct 21, 2026
4918b94
test(dgw): pin that a cut line still parses in recording search
irvingoujAtDevolution Oct 21, 2026
8da08df
test(dgw): match an escaped value in a cut line in recording search
irvingoujAtDevolution Oct 21, 2026
3ee0497
docs(dgw): wrap the cut line sentence at the line limit
irvingoujAtDevolution Oct 21, 2026
880576a
docs(dgw): state the search intent reasons as rationale
irvingoujAtDevolution Oct 21, 2026
ca3fb61
style(dgw): wrap a recording search test line as rustfmt does
irvingoujAtDevolution Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion devolutions-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ cfg-if = "1.0"
url = { version = "2.5", features = ["serde"] }
ulid = { version = "1", features = ["serde"] }
uuid = { version = "1.23", features = ["v4", "serde"] }
time = { version = "0.3", default-features = false, features = ["std", "serde", "formatting"] }
time = { version = "0.3", default-features = false, features = ["std", "serde", "formatting", "parsing"] }
parking_lot = "0.12"
anyhow = "1.0"
thiserror = "2"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub(crate) mod search;

use std::fs;
use std::io::{self, Seek as _, Write as _};
use std::net::SocketAddr;
Expand All @@ -12,7 +14,7 @@ use axum::extract::ws::WebSocket;
use axum::extract::{self, ConnectInfo, Query, State, WebSocketUpgrade};
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue};
use axum::response::Response;
use axum::routing::{delete, get};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use bytes::Bytes;
use cadeau::xmf;
Expand Down Expand Up @@ -59,6 +61,7 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
.route("/play", get(get_player))
.route("/play/{*path}", get(get_player))
.route("/shadow/{id}", get(shadow_recording))
.route("/search", post(search::search_recordings))
.with_state(state)
}

Expand Down
42 changes: 42 additions & 0 deletions devolutions-gateway/src/api/jrec/search.intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## What is searched

Search reads only the files listed under `artifacts` in `recording.json` whose type is `slog`.
Recording files listed under `files` are not searched, a `.slog` recording included.
Comment on lines +3 to +4

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: Sounds to me that searching into .slog files that are in files could be useful as well, but I also get why we could prefer avoiding that. Just curious: did you consider how this feature could be extended if we wanted to support for search sources?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up question: Also this intent reads as we take all the .slog files in artifacts, with no regards for the "category"; one category is ai-transcript IIRC. Did you consider having requests explicitly select based on that? E.g.: search all AI transcripts (as opposed to search everything, including any new type of artifact we may add in the future)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you, we only have ai transcript for now however. We can certainly extend it further if we have a second type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: Sounds to me that searching into .slog files that are in files could be useful as well, but I also get why we could prefer avoiding that. Just curious: did you consider how this feature could be extended if we wanted to support for search sources?

Yes, I'd like to keep it scoped for now, only implement what we need. If further, as product request grows, we can easily extend the request body to include different kind of files.


A line holding a JSON object is matched case-insensitively against the object's string values, at any depth and once unescaped; field names, numbers, booleans and null never match.
Any other line is matched case-insensitively against its raw text, so that any kind of artifact, present or future, can be searched.
A line cut at 256 KiB follows the same rule: what was read is matched by its string values when it parses as a JSON object, and as raw text otherwise.

Values are matched rather than raw text because users search the text they see: raw matching would make a field name such as `description` match every line, and would miss text that JSON escapes, such as `DOMAIN\user` stored as `DOMAIN\\user`.
The rule never depends on field names, so a producer can change the fields it writes without any change to search.
Per-field search, such as matching an entry's `description` only, is not supported: the fields a producer writes are unknown to Gateway, which only cares about values.

## Hits

A hit is a line that matches the query.
A hit carries the recording ID, the file name, the line number counting from 1, the byte offset of the line, and the raw line itself as a string.
Line numbers count from 1 like the slog package in `webapp/packages/session-recording-log`, so that a hit and that package's own search give a line the same number.
Gateway does not require a line to be JSON: a `.slog` is NDJSON by format, but a line holds whatever its producer pushed, such as a half-written last line while the push is live.
A line longer than 256 KiB is matched on its first 256 KiB and returned cut, with `truncated: true`; the caller fetches the file for the whole line.

## Access

A valid token with the `gateway.recordings.search` scope is the only check, and the provisioner that issues the token decides who gets one.
Search has its own scope because `gateway.recordings.read` only lists recording IDs and must not reveal log contents.

## Request

`recordings` is `"all"` or a list of recording IDs, and stays open to new forms added without breaking existing callers.
`"all"` orders recordings newest first by start time, then by ID, so that the order is the same on every request; a list keeps its order and searches a repeated ID once.

The optional `startTime` range takes RFC 3339 date-times and keeps a recording when `from <= startTime < to`, using the start time in `recording.json`, never anything inside a log.
A missing end leaves that side of the range open.

## Limits

One search reads about 16 MiB of logs at most and returns at most 1,000 hits, so that searching every recording takes a bounded time.

## Later

Regular expressions or wildcards, a total hit count, and combining words are wanted later, and the request shape must not preclude them.
Choosing which artifact kinds to search, or searching other sources such as the `.slog` recordings under `files`, is wanted later; the request shape must not preclude it either.
Loading
Loading