Commit Graph
763 Commits
Author SHA1 Message Date
Cameron CordesandClaude Opus 5.5 0b87320ef7 fix(video): make NVENC honour forced HLS keyframes
-force_key_frames asks for a keyframe every 3 s, but h264_nvenc inserts
those as plain I-frames unless -forced-idr is set. The HLS muxer only
splits on IDR frames, so NVENC transcodes fell back to the encoder's
default 250-frame GOP: 8.34 s segments at 29.97 fps, ~10 s at 25 fps.
On prod ~480 playlists have a target duration above 4 s. Long segments
mean more data before playback starts and coarser seeks.

Verified on a 10 s 29.97 fps clip: segments went from 8.34 + 1.67 s to
3.003 x 3 + 1.001 s, matching the libx264 path, which was already
correct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:03:18 -04:00
Cameron CordesandClaude Opus 5.5 83b1923e25 fix(video): keep source frame timing when transcoding to HLS
Variable-frame-rate sources (phone clips that change capture rate,
screen recordings) report r_frame_rate as their 1/90000 timebase. The
HLS transcode used the mpegts muxer's default CFR mode, so ffmpeg
duplicated frames to fill 90000 fps: on prod, three 2.6-5 s phone clips
became 3-second "segments" of up to 446 MB / 250k frames. Players
download the whole thing before starting and can't seek within it.

-fps_mode vfr passes source timestamps through. Verified on
20220208_164356_01.mp4: 233 frames over 2.578 s in, 233 frames over
2.578 s out, 5.9 MB vs 221 MB. The copy path already keeps timestamps
and is unchanged. Requires ffmpeg >= 5.1 (prod runs 8.0.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:58:13 -04:00
Cameron CordesandClaude Opus 5.5 052609d604 docs: document HLS_COPY_MAX_BITRATE
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:58:12 -04:00
Cameron CordesandClaude Opus 5.5 1279cb8430 fix(video): stop stream-copying oversized sources into HLS
HLS generation stream-copied any h264 source with no rotation and a
GOP within one segment, which keeps the source's resolution and
bitrate. Mirrorless footage (4K at 100-160 Mbps) came through as-is,
giving ~50 MB 3-second segments; playback start and seeks stalled on
anything slower than wired gigabit. On prod, 16 playlists run above
50 Mbps, versus ~10 Mbps typical for phone clips.

The stream probe now also reads width, height and bit_rate (stream
level, falling back to format level; all header fields, so the probe
stays header-only). Copy additionally requires the short side to be
<= 1080 and the bitrate <= HLS_COPY_MAX_BITRATE (default 20 Mbps).
Unknown size or bitrate transcodes, matching the GOP probe's fallback.

The transcode scale filter capped width at 1080, so landscape sources
came out at 1080x608. It now caps the short side instead: 3840x2160
-> 1920x1080, 2160x3840 -> 1080x1920, smaller sources unchanged
(verified with ffmpeg).

Existing oversized playlists need regenerating after deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:46:46 -04:00
cameron ff1ba1fcd6 Merge pull request 'fix(dates): read exiftool's word-form GPS hemisphere refs (#163)' (#165) from fix/exiftool-gps-hemisphere into master
Reviewed-on: #165
2026-10-13 14:32:36 +00:00
Cameron CordesandClaude Opus 5.5 5b6ffd7040 fix(dates): read exiftool's word-form GPS hemisphere refs (#163)
The exiftool branch matched GPSLatitudeRef / GPSLongitudeRef with an
exact one-letter compare ("S" / "W"), but exiftool's print conversion
(which parse_gps_dms relies on) renders them as "South" / "West". The
flags never set, so every western longitude and southern latitude
reached the camera-offset cascade positive and the GPS step looked up
the mirrored point: northeast Ohio became China, offset UTC+8. On prod,
all 280 western-hemisphere `gps`-sourced rows carry +480, putting their
date_utc 12-13 h off.

gps_ref_is matches the first letter case-insensitively, the same rule
as exif::extract_gps_coordinate (whose kamadak path was already
correct; the stored gps_latitude / gps_longitude columns are unaffected).

Tests use exiftool's real output strings ("West" / "South", verified
against exiftool 12.76 with the resolver's -j -q -G1 flags), including
a southern-hemisphere case; the new payload test fails on the old
compare. The existing test fed "W", which exiftool doesn't emit by
default, so it couldn't catch this.

Existing rows need a re-resolve after deploy; see #163 for the reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:32:25 +00:00
cameron 4da2d4d0a8 Merge pull request 'docs: stack architecture diagrams (Apollo / ImageApi / FVR)' (#164) from docs into master
Reviewed-on: #164
2026-10-13 14:05:28 +00:00
Cameron CordesandClaude Opus 5.5 7077aee4ca docs: stack architecture diagrams for Apollo / ImageApi / FVR
Mermaid sources for the system context, data ownership, photo ingest,
watcher tick, Apollo photo matching and photo insight chat, traced from
the current code of all three repos. Allow-lists docs/architecture/
alongside docs/adr/ and docs/agents/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:55:43 -04:00
cameron d50cad7421 Merge pull request 'feat(dates): photo date filters on the true instant (#152)' (#162) from feature/true-instant-date-filters into master
Reviewed-on: #162
2026-10-11 02:04:08 +00:00
Cameron CordesandClaude Opus 5.5 19fbf43d40 feat(dates): photo date filters on the true instant (#152)
date_from / date_to on /photos, /photos/exif and unified search ran on
the legacy mixed-convention date_taken, so a photo could display inside
a range yet be filtered out of it by up to its UTC offset (or by years,
for rows the filename class re-dated).

The filter lives in the shared ExifDao::query_by_exif, so it's fixed
once there:

- query_by_exif takes Option<date_view::InstantRange> (bounds + display
  and server zones) instead of raw date_from / date_to.
- SQL pre-filter: converted rows on date_utc exactly; unconverted rows
  (drain pending, permanent floor) on date_taken widened by
  LEGACY_INSTANT_BAND_SECS (±15 h, now shared with /memories). Converted
  rows are deliberately not bounded by date_taken: a re-dated row's
  legacy value can sit years from its instant.
- Exact pass in Rust via InstantRange::contains → row_true_instant, so
  every returned row's true instant is inside the range.

Calendar days now mean the owner's days: nl_query::iso_to_unix builds
day bounds in the display zone (it used UTC midnights, which only lined
up with the legacy wall-as-UTC column), and unified search's "today" is
the owner's today rather than UTC's. The midnight-with-DST-gap fallback
moves from memories_anchor into date_view::day_start_instant.

Compatibility: clients already sending real instants (Apollo's
/photos/exif windows) get correct edges with no change. Clients sending
UTC-midnight day bounds see ranges shift by up to the UTC offset until
they send display-zone bounds.

No index on date_utc; the OR clause scans, which is milliseconds at
personal-library scale (same as /memories today).

dev/e2e check gains /photos/exif wall_clock + true_instant assertions and
range-edge checks ([t, t] in, [.., t-1] and [t+1, ..] out). Not run here:
the harness needs the WSL + Windows toolchain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:52:48 -04:00
cameron 9e780774d9 Merge pull request 'feat(dates): true_instant + wall_clock on /photos/exif (#113)' (#161) from feature/exif-summary-instant-fields into master
Reviewed-on: #161
2026-10-11 01:42:46 +00:00
Cameron CordesandClaude Opus 5.5 f1a7fb4ecc feat(dates): true_instant + wall_clock on /photos/exif (#113)
/photos/exif is Apollo's photo-to-track matching feed, but unlike
/image/metadata and /photos/gps-summary it only carried the raw
new-contract triple. date_utc is NULL for unconverted rows and, by
design, forever for the drain's edited/exported floor (#120), so a
client reading date_utc alone would have to re-implement the
per-source legacy conventions to cover those rows: the re-anchoring
ADR-0001 says consumers delete, not migrate.

ExifSummary now carries:
- true_instant: date_view::row_true_instant (date_utc when converted,
  else the per-source fallback), for instant-to-instant matching.
- wall_clock: date_view::row_wall_clock, rendered verbatim.

The row → wire mapping moves to ExifSummary::from_row so the tests
cover the real projection, including an unconverted row falling back
to the same instant and wall as its converted twin. Additive: legacy
fields are unchanged, so current clients see the same values.

The date_from/date_to filter still runs on the legacy column; that's
#152, which now explicitly covers /photos/exif too.

Unblocks Apollo #37.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:15:41 -04:00
cameron 991622af2a Merge pull request 'feat(memories): optional reference_date to browse relative to another day' (#159) from feature/memories-reference-date into master
Reviewed-on: #159
2026-10-11 00:29:19 +00:00
Cameron CordesandClaude Opus 5.5 3563c3de8d feat(memories): optional reference_date to browse relative to another day
GET /memories accepts reference_date=YYYY-MM-DD (a day in the owner's
display zone). It re-anchors both the span match (day_in_span's "today")
and the pre-filter window, which ends at the end of that day, so the
20-year lookback is measured from it and a future date surfaces
everything taken so far. Omitted → today, unchanged. Malformed → 400.

Reels stay anchored on now (MVP): the reel selector passes None.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:29:33 -04:00
cameron c256829615 Merge pull request 'Feature/no original fallback' (#158) from feature/no-original-fallback into master
Reviewed-on: #158
2026-10-04 00:50:14 +00:00
Cameron CordesandClaude Opus 5.5 4f839954b0 chore(idea): mark tests/ as a test source root
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:01:25 -04:00
Cameron CordesandClaude Opus 5.5 38ac3656c6 fix(image): never stream originals into thumb slots; unique preview tempfiles
A thumb miss (no thumb on disk, or an `.unsupported` sentinel) fell
through to NamedFile on the original. For a 12432x3504 panorama that
decodes to 174MB, past Android's 100MB draw limit, and crashed the
FileViewer app natively on the Memories tab. Still-image thumb misses
now serve the 2048px large preview, or 404 if even that can't be made.

Large/xlarge generation wrote to a shared `<dest>.jpg.tmp`, so two
concurrent requests for the same file (common: tile remounts) raced and
the loser's rename failed with os error 2, falling back to the original.
Generation now goes through a per-call tempfile, and a failed request
still serves a preview a concurrent request produced.

Large and xlarge branches are folded into one `serve_preview` helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:53:39 -04:00
cameron 98527bb81f Merge pull request 'fix(memories): sort Month span by time within a day; test the real sorts' (#156) from fix/memories-month-sort into master
Reviewed-on: #156
2026-09-30 03:07:39 +00:00
Cameron CordesandClaude Opus 5.5 75581b3f6a test(memories): test the real Week sort; cover every span via sort_memories
test_week_span_sorting_chronological_by_day sorted its own inline copy
(day-of-month across years), so it kept passing after 7d2a314 made the
real Week sort chronological (full date, then time), and it disagreed
with production. Replace it with a test that calls sort_memories, and
add one for Day, so all three spans are pinned against the real sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:03:57 -04:00
Cameron CordesandClaude Opus 5.5 4feffb62bb fix(memories): sort Month span by time within a day
The Month sort keyed on the display day only. The sort is stable, so a
day's photos kept DB order, merged across libraries, and their times
jumped back and forth. Month now sorts like Week: full date, then
timestamp (undated items last).

The sort moves into sort_memories so it can be tested directly; the new
test covers a multi-library, out-of-order day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:03:56 -04:00
cameron fd9ecbe53b Merge pull request 'feat(otel): export logs through the tracing pipeline, correlated with traces (#147)' (#155) from feature/tracing-log-export into master
Reviewed-on: #155
2026-09-30 03:03:14 +00:00
Cameron CordesandClaude Opus 5.5 3b3e6bdb39 feat(otel): export logs through the tracing pipeline, correlated with traces (#147)
Logs were a separate pipeline: env_logger in debug (never exported) and
opentelemetry-appender-log in release (exported without trace context,
so SigNoz could not link logs to traces).

- tracing-subscriber `tracing-log` feature: try_init installs LogTracer,
  so log:: records become tracing events: one console format, and
  RUST_LOG per-module directives still apply to them.
- opentelemetry-appender-tracing exports events as OTel log records when
  OTLP_OTLS_ENDPOINT is set (any build), stamped with the enclosing
  span's trace_id/span_id. A per-layer filter drops the exporter's own
  crates.
- The OTel span layer keeps only WARN/ERROR events as span events; the
  info/debug copies duplicated the correlated logs.
- Remove env_logger init in the server, init_logs, and the unused
  opentelemetry-appender-log / opentelemetry-stdout deps.
- tests/otel_log_correlation.rs pins correlation (native + bridged),
  the span-event filter, and per-module filtering of bridged records.
- Constitution 1.2.0 (Principle V logging rule), CLAUDE.md, README,
  .env.example.

Closes #147

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:56:10 -04:00
cameron 79b339a2a6 Merge pull request 'test(filename_audit): seed library 2 before its image_exif row (#151)' (#154) from fix/filename-audit-test-fk into master
Reviewed-on: #154
2026-09-30 01:53:20 +00:00
Cameron CordesandClaude Opus 5.5 8a5a7c19d7 test(filename_audit): seed library 2 before its image_exif row (#151)
run_audit_skips_disabled_and_offline_libraries inserted an image_exif
row for library 2, which migrations never create, tripping the
image_exif.library_id foreign key. Insert the libraries row first so
the suite is green on master again.

Also fix the cmp_owned clippy warning in the filename_audit bin.

Closes #151

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:31:53 -04:00
cameron 053314137c Merge pull request 'docs(agents): the wayfinder:* labels exist now' (#153) from docs/wayfinder-labels into master
Reviewed-on: #153
2026-09-30 00:48:08 +00:00
Cameron CordesandClaude Opus 5.5 39771df86b docs(agents): the wayfinder:* labels exist now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:45:47 -04:00
cameron 810db329ab Merge pull request 'docs: LOCAL_BIND_URL in README (constitution IV) + tea label behavior in tracker docs' (#150) from docs/local-bind-url into master
Reviewed-on: #150
2026-09-30 00:44:41 +00:00
Cameron CordesandClaude Opus 5.5 d29d3c0e9b docs(agents): labels must exist before tea can apply them
The tracker doc said labels that don't exist are created on first use.
They aren't: `tea issues create --labels` and `issues edit --add-labels`
silently drop unknown names (the command succeeds, the issue stays
unlabelled) — observed filing #151/#152 while the repo had no labels.
Document creating them with `tea labels create` and checking the result.
The five triage labels now exist on the server; the wayfinder:* family
still has to be created before first use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:43:20 -04:00
Cameron CordesandClaude Opus 5.5 af12061cc0 docs: document LOCAL_BIND_URL (constitution IV)
#145 added the optional LOCAL_BIND_URL (the secondary loopback listener,
default localhost:8088, empty = skip) but documented it only in
CLAUDE.md. Constitution IV requires every new environment variable in
the README environment section; also add it to .env.example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:40:45 -04:00
cameron aeeb05b289 Merge pull request 'docs: amend constitution to v1.1.0 — Observability after the tracing migration' (#149) from docs/tracing-constitution into master
Reviewed-on: #149
2026-09-29 23:49:09 +00:00
cameron a869bf37c9 Merge branch 'master' into docs/tracing-constitution 2026-09-29 23:48:58 +00:00
Cameron CordesandClaude Opus 5.5 fbe3d06ae5 docs: amend constitution to v1.1.0 (Observability for tracing migration)
Principle V now reflects the merged tracing migration (#148):
- tracing + tracing-opentelemetry spans in every build, with OTLP export
  gated on OTLP_OTLS_ENDPOINT;
- no manual Context threading;
- every spawn / fan-out carries its parent span;
- RUST_LOG gates span export (run at info/debug).
Layer and Tech Stack wording matches.

Also corrects stale "release builds only" telemetry notes in CLAUDE.md and
.env.example, updates the CLAUDE.md dependency list, and fixes the README's
RUST_LOG default. It also documents OTLP_OTLS_ENDPOINT in the README, as
Principle IV requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:03:44 -04:00
cameron 3ee2517a9e Merge pull request 'feat(dates): serve wall_clock + true_instant to clients (#113, #120)' (#146) from feature/wall-clock-dates into master
Reviewed-on: #146
2026-09-29 22:01:23 +00:00
Cameron CordesandClaude Opus 5.5 9269b5c9fd Dates: true_instant on metadata responses for instant matching
The mobile viewer anchored its "messages around this photo" window on
the legacy date_taken (a wall clock stored as UTC for EXIF rows), while
SMS-API dates are true instants: the window was off by the photo's UTC
offset (4-5 h for photos taken at home; ImageApi #113 user story 3).
Verified SMS-API side: importers store backup epochs as UTC, and 40/40
sampled rows equal their source epochs exactly.

`/image/metadata` (+ GPS / date mutation responses) now carries
`true_instant` from `date_view::file_true_instant`: `row_true_instant`,
else the filename-class instant, else the earliest fs time. Server-side
for the same reason as `wall_clock`: the drain's permanent floor rows
have no `date_utc`. `set_capture_dates` fills both fields at all four
response sites.

dev/e2e: the manifest records each fixture's expected instant (the wall
read in its GPS zone, else America/New_York) and `e2e.sh check` asserts
it (97/97).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:55:29 -04:00
Cameron CordesandClaude Opus 5.5 bc41c737e5 dev/e2e: wall_clock regression check + deterministic fixture dates
`e2e.sh check` compares every indexed fixture's manifest wall clock with
the `wall_clock` reported by /image/metadata, /memories and
/photos/gps-summary; any disagreement fails.

- Fixture videos are stamped like phone containers: the America/New_York
  wall as a UTC creation_time (the label previously showed the UTC time).
- mtimes are pinned in America/New_York instead of the host's zone.
- The e2e env sets TZ=America/New_York (the #120 preflight): the Windows
  exe has no /etc/timezone, so server_zone() was otherwise None.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:54:47 -04:00
Cameron CordesandClaude Opus 5.5 e2f33684c8 Dates: serve the display wall clock (wall_clock) to clients
Every payload a client renders a capture time from now carries
`wall_clock`, a naive ISO-8601 string computed by the existing
`date_view::row_display_wall`: the camera's wall clock (true instant +
camera offset; the owner's display zone without an offset), with the
per-source fallback for unconverted rows. Clients render it verbatim and
do no zone math, which is the FileViewer-React #33 / Apollo #37 flip with
the rule kept server-side: the drain's permanent `date_utc IS NULL` floor
(#120: edited/exported files) would otherwise force every client to
carry the per-source legacy conventions forever, and insight prompts
already use the same function, so prompt and UI agree by construction.

- `/image/metadata` (+ the GPS / date / date-clear mutation responses):
  `file_wall_clock` — the row's wall, else the filename-class date, else
  the earliest fs time in the display zone (unindexed files).
- `/memories` items and `/photos/gps-summary` photos: `row_wall_clock`.
- `get_all_with_gps` returns full rows instead of a 7-tuple that dropped
  `date_taken_source`, which unconverted rows need.
- `date_view::owner_display_zone` resolves prefs -> app default per request.

Legacy fields are untouched, so current clients see byte-identical
responses (additive; safe to deploy ahead of the client flip, per #120).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:54:46 -04:00
cameron e0c6c696f2 Merge pull request 'Tracing: migrate spans from raw OTel SDK to tracing + tracing-opentelemetry (#135)' (#148) from feature/tracing-migration into master
Reviewed-on: #148
2026-09-29 21:52:00 +00:00
Cameron CordesandClaude Opus 5.5 517d1d4b62 Docs: RUST_LOG also gates span export (#135)
The subscriber's single EnvFilter fronts both the console and the OTel
layer, so RUST_LOG decides which spans exist for export. Documented in
CLAUDE.md (supported range info/debug) and at the filter in init_tracing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:35:34 -04:00
Cameron CordesandClaude Opus 5.5 f510940819 Tracing: carry span into insight panic-isolation tasks; root the watcher boot probe (#143)
Found by the parity capture: both insight-generation handlers run the
generation inside a nested tokio::task::spawn (panic isolation) that was
not instrumented, so the background ai.insight.* trace still split off
from its 202 request. The watcher's boot-time availability probe ran
before the first tick span, leaving its count_for_library DB spans as
roots; it now opens `watcher.initial_probe`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:13:36 -04:00
Cameron CordesandClaude Opus 5.5 983102c1a5 Tracing: cargo fmt tests/otel_propagation.rs (#143)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:04:08 -04:00
Cameron CordesandClaude Opus 5.5 26115b3378 Tracing: root spans for background jobs, retire raw OTel spans (#142)
Background work has no request parent, so each job now opens a named
tracing root and carries it into its fan-out:

- watcher: `watcher.tick` (scan=quick|full) entered per tick on the
  watcher's std thread, `watcher.library` per library, and
  `watcher.playlist_cleanup` per orphaned-HLS pass. face_watch /
  clip_watch carry the ambient span into every per-candidate
  tokio::spawn and give each call its own `face_watch.detect` /
  `clip_watch.encode` span.
- thumbnails: `creating thumbnails` via #[instrument]; per-video spans on
  rayon workers name their parent explicitly.
- video actors: `playlistgenerator.generate_playlist` and
  `preview_clip.generate` are tracing roots that instrument their futures.
- reels: `reel.job` (created before the spawn, so on-demand jobs stay in
  the request trace), `reel.pregen_batch`, `reel.cache_sweep`.
- RefreshThumbnailsMessage and the dead-code GIF job move to tracing.

No raw OpenTelemetry span creation is left outside otel.rs;
`global_tracer` is removed.

tests/otel_background.rs pins the std-thread tick -> block_on -> spawn
shape and the rayon explicit-parent shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00
Cameron CordesandClaude Opus 5.5 b6deca2a52 Tracing: DAO layer + handler spans on tracing, drop all Context params (#141)
trace_db_call no longer takes an OpenTelemetry Context: it opens a
db.<query_type>.<operation> span (same name via otel.name, same
db.query_type / db.operation attrs) on the caller's ambient tracing span
and runs the DAO body inside it. The cx / ctx / context parameter is gone
from every DAO trait method, impl and call site, and from the helpers and
background drains that only threaded it.

The remaining request-handler spans (files, tags, faces, image, video,
favorites, duplicates, memories) move to #[instrument]; the
Context::current_with_span / with_remote_span_context plumbing is gone, as
is extract_context_from_request (the tracing-actix-web root span covers
it). otel::set_span_attributes replaces BoxedSpan::set_attributes.

Background-job raw spans (thumbnails, video actors) are left for #142.

tests/otel_db_spans.rs pins name, attributes, status and nesting (across
an instrumented spawn); otel_traceparent now uses an #[instrument]
handler. CLAUDE.md documents the tracing conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00
Cameron CordesandClaude Opus 5.5 1c508cdabd Tracing: LLM clients + streaming spans on tracing, drop cx threading (#140)
LlmClient methods no longer take an OpenTelemetry Context. llm.request is
now a tracing span (otel.kind=client, llm.provider/operation/model) that
parents on the caller's ambient span; traceparent injection reads the
span's OTel context via OpenTelemetrySpanExt.

The src/ai feature spans (handlers, insight generation, chat, persona
chat, daily summaries, TTS) move to #[instrument] / info_span!, and every
LLM-bearing tokio::spawn carries its parent span with .instrument(): the
streaming chat turn (chat_turn_stream_inner), async chat / persona turns,
background insight jobs and TTS jobs. Sub-spans in the agentic loops
(ai.chat.loop, ai.agentic.loop) instrument each LLM / tool await.

DAO calls keep their explicit Context (derived from the tracing span)
until the DAO migration (#141).

tests/otel_llm_spans.rs pins nesting + traceparent for the blocking and
streaming paths across a spawn on a multi-thread runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:02:40 -04:00
Cameron Cordes 7a59afc62b Tracing: per-request root span via tracing-actix-web middleware (#139)
Add a conventional per-request OTel server span using the standard
tracing-actix-web middleware (TracingLogger) as the outermost middleware,
so the whole request lives under one root span and an incoming W3C
traceparent is extracted onto it via the global text-map propagator
(installed in #138).

extract_context_from_request now sources the operation span's parent from
that root span (tracing::Span::current().context()) instead of re-parsing
the header, so per-handler operation spans nest under the root span as
one trace tree rather than duplicating it as a sibling. Header parsing
remains as a fallback for handlers invoked without the middleware (tests).

The literal per-handler span boilerplate is intentionally NOT removed here;
it becomes #[instrument] in the LLM (#140) and DAO (#141) migrations, where
the &Context threading disappears entirely.

tests/otel_traceparent.rs pins the behavior end-to-end: an incoming
traceparent's trace_id propagates to the request, the root span is parented
on the caller's (remote) span, and the operation span nests under the root
span rather than alongside it.
2026-09-29 17:02:40 -04:00
Cameron Cordes e93f6b6d69 Otel: unify tracing subscriber across dev+release; env-gated OTLP export (#138)
Install ONE `tracing` subscriber in both build profiles via
`otel::init_tracing()` (previously release-only, so spans were a no-op in
dev). Behaviour is now environment-driven, not build-profile-driven:

- OTLP span export happens only when `OTLP_OTLS_ENDPOINT` is set
  (release / staging). Unset -> spans are still created with valid
  contexts (so outgoing `traceparent` injection keeps working) but export
  nowhere; the console `fmt` layer is what shows them.
- `tracing_subscriber::registry()` + `EnvFilter` (RUST_LOG) +
  `OpenTelemetryLayer` + console `fmt` layer, installed in both builds.
- Set the global W3C `TraceContextPropagator`: required by the
  `tracing-actix-web` request middleware (#139) and by the outgoing
  traceparent helpers, both of which read the *global* propagator.
- `tracing-log` bridge stays OFF (`default-features = false`) so the console
  fmt layer prints tracing spans only; `log` records keep their existing
  path (env_logger in debug, OTel logs in release) without double-printing.

Deps: + tracing, tracing-subscriber (env-filter/fmt/ansi), tracing-opentelemetry
0.32 (pins opentelemetry 0.31 + otel_sdk 0.31, no version bump); opentelemetry_sdk
gains the `testing` feature.

Add `tests/otel_propagation.rs`: the regression matrix from spike #133, pinning
the invariant the whole migration rests on -- an OTel context does NOT cross a
bare `tokio::spawn` on a multi-thread runtime (S2/S4 orphan onto a fresh trace)
but DOES with an explicit parent carry across the spawn (S6 `.instrument()`,
S7 `span.enter()`). This is why every spawn in the codebase must carry the parent.

clippy --all-targets clean; 690 lib + 775 integration + regression + 4 doc-tests pass.
2026-09-29 17:02:40 -04:00
cameron 8284509c90 Merge pull request 'dev/e2e: disposable server for app e2e / agent UI testing' (#145) from feature/e2e-dev-server into master
Reviewed-on: #145
2026-09-29 19:56:58 +00:00
Cameron CordesandClaude Opus 5.5 f00a309a24 dev/e2e: disposable server for app e2e / agent UI testing
`dev/e2e/e2e.sh up` runs an isolated ImageApi (port 8090, own build dir,
db, thumbnails, HLS) next to a real local server, serving a copy of a
synthetic fixture library. Every fixture image has its path, date and
scenario burned into the pixels; manifest.json records expected dates,
GPS, cameras, tags and counts. `reset` restores the baseline.

- LOCAL_BIND_URL makes the hardcoded localhost:8088 listener
  configurable (default unchanged; empty disables it) so a second
  instance can start.
- create_user bin: the server has no registration route; idempotent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:02:49 -04:00
cameron da3901c564 Merge pull request 'Prefs: owner defaults (model, backend, persona_id, reel span/length)' (#129) from feature/prefs-owner-defaults into master
Reviewed-on: #129
2026-09-29 01:50:07 +00:00
Cameron Cordes f850c535f1 Otel: thread request context through streaming chat path (#129 review)
The legacy SSE streaming path (chat_turn_stream -> run_streaming_turn ->
run_bootstrap/continuation_streaming) built its LLM spans from
Context::current(), which is always the empty root context here: the codebase
threads OTel contexts explicitly and never attaches one, there is no
context-aware tokio layer, and the turn runs on a tokio::spawn. So every
llm.request span on that path was an orphaned root span, uncorrelated with the
HTTP request.

Thread the handler's extracted request context down through
chat_turn_stream -> chat_turn_stream_inner -> run_streaming_turn ->
run_bootstrap_streaming / run_continuation_streaming, and use it for the
describe_image and agentic-loop calls. Mirrors the non-streaming path, which
already threads parent_cx -> loop_cx.

Also rename the block-scoped db 'cx' in run_streaming_turn to db_cx so it no
longer shadows the new parameter.
2026-09-28 21:41:38 -04:00
Cameron Cordes 10ed84d7b4 Otel: dedupe LLM span error/status + traceparent helpers (#130 follow-up)
Addresses the P2 refactoring follow-ups parked on issue #130:

- otel::with_traceparent(builder, &SpanContext) — one shared W3C
  traceparent injector. Replaces the 4-line inline header loop in
  llamacpp (3x) and openrouter (3x) and removes ollama's private
  inject_traceparent method (4 call sites now use the shared helper).
  Takes &SpanContext (not &dyn Span) because the Span trait is not dyn
  compatible in opentelemetry 0.31.

- otel::record_http_failure(span, status, message) and
  otel::set_http_status(span, status) — collapse the repeated
  'set http.status_code attribute + set_status(Status::error)'
  boilerplate across all three clients (~10 sites).

Net -45 lines. The remaining if-let-Some(span.as_mut()) guards (token
attributes, parse-failure status) stay inline rather than growing a
SpanHandle wrapper type for a pattern that's already readable.
2026-09-28 10:35:28 -04:00