diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-19 21:54:54 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-19 21:54:54 -0500 |
| commit | fcb8629182fa1552e4a840ccd4ec0aa2b8042cc0 (patch) | |
| tree | e389479cf9349fbdab107da739bceef11cf8e7ee /Omni/Ava | |
| parent | e856c766584ed933bed0b79c7ef47b6d98b0fb7e (diff) | |
feat(ava): add tool trace viewer mini-app
- Add SQLite storage for tool traces (Omni/Ava/Trace.hs)
- Add web server to serve trace viewer (Omni/Ava/Web.hs)
- Add HTML/CSS/JS trace viewer UI (Omni/Ava/Web/trace.html)
- Integrate trace storage into Engine.hs tool execution callback
- Add trace links to Telegram responses when AVA_WEB_URL is set
- Configure Tailscale Funnel for public access
- Fix pre-push hook variable scope bug
- Add direnv, bash, nix to Ava service PATH
- Add mustache dep to Ava.hs for template rendering
Epic: t-272
Diffstat (limited to 'Omni/Ava')
| -rw-r--r-- | Omni/Ava/Trace.hs | 148 | ||||
| -rw-r--r-- | Omni/Ava/Web.hs | 84 | ||||
| -rw-r--r-- | Omni/Ava/Web/trace.html | 71 |
3 files changed, 303 insertions, 0 deletions
diff --git a/Omni/Ava/Trace.hs b/Omni/Ava/Trace.hs new file mode 100644 index 0000000..6dbdf51 --- /dev/null +++ b/Omni/Ava/Trace.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Tool trace storage for Ava. +-- +-- Records tool execution traces for debugging and analytics. +-- +-- : out omni-ava-trace +-- : dep aeson +-- : dep sqlite-simple +-- : dep uuid +module Omni.Ava.Trace + ( TraceRecord (..), + insertTrace, + getTrace, + cleanupOldTraces, + main, + test, + ) +where + +import Alpha +import Data.Aeson ((.=)) +import qualified Data.Aeson as Aeson +import qualified Data.Text as Text +import qualified Data.UUID as UUID +import qualified Data.UUID.V4 as UUID +import qualified Database.SQLite.Simple as SQL +import qualified Database.SQLite.Simple.ToField as SQL +import qualified Omni.Test as Test + +main :: IO () +main = Test.run test + +test :: Test.Tree +test = + Test.group + "Omni.Ava.Trace" + [ Test.unit "TraceRecord JSON roundtrip" <| do + let tr = + TraceRecord + { trcId = "trace-123", + trcCreatedAt = "2024-01-15T10:30:00Z", + trcToolName = "web_search", + trcInput = "{\"query\":\"test\"}", + trcOutput = "{\"results\":[]}", + trcDurationMs = 150, + trcUserId = Just "user-456", + trcChatId = Just "chat-789" + } + case Aeson.decode (Aeson.encode tr) of + Nothing -> Test.assertFailure "Failed to decode TraceRecord" + Just decoded -> do + trcToolName decoded Test.@=? "web_search" + trcDurationMs decoded Test.@=? 150 + ] + +data TraceRecord = TraceRecord + { trcId :: Text, + trcCreatedAt :: Text, + trcToolName :: Text, + trcInput :: Text, + trcOutput :: Text, + trcDurationMs :: Int, + trcUserId :: Maybe Text, + trcChatId :: Maybe Text + } + deriving (Show, Eq, Generic) + +instance Aeson.ToJSON TraceRecord where + toJSON tr = + Aeson.object + [ "id" .= trcId tr, + "created_at" .= trcCreatedAt tr, + "tool_name" .= trcToolName tr, + "input" .= trcInput tr, + "output" .= trcOutput tr, + "duration_ms" .= trcDurationMs tr, + "user_id" .= trcUserId tr, + "chat_id" .= trcChatId tr + ] + +instance Aeson.FromJSON TraceRecord where + parseJSON = + Aeson.withObject "TraceRecord" <| \v -> + (TraceRecord </ (v Aeson..: "id")) + <*> (v Aeson..: "created_at") + <*> (v Aeson..: "tool_name") + <*> (v Aeson..: "input") + <*> (v Aeson..: "output") + <*> (v Aeson..: "duration_ms") + <*> (v Aeson..:? "user_id") + <*> (v Aeson..:? "chat_id") + +instance SQL.FromRow TraceRecord where + fromRow = + (TraceRecord </ SQL.field) + <*> SQL.field + <*> SQL.field + <*> SQL.field + <*> SQL.field + <*> SQL.field + <*> SQL.field + <*> SQL.field + +instance SQL.ToRow TraceRecord where + toRow tr = + [ SQL.toField (trcId tr), + SQL.toField (trcCreatedAt tr), + SQL.toField (trcToolName tr), + SQL.toField (trcInput tr), + SQL.toField (trcOutput tr), + SQL.toField (trcDurationMs tr), + SQL.toField (trcUserId tr), + SQL.toField (trcChatId tr) + ] + +insertTrace :: SQL.Connection -> TraceRecord -> IO Text +insertTrace conn tr = do + tid <- + if Text.null (trcId tr) + then (Text.pack <. UUID.toString) </ UUID.nextRandom + else pure (trcId tr) + let trWithId = tr {trcId = tid} + SQL.execute + conn + "INSERT INTO tool_traces (id, created_at, tool_name, input, output, duration_ms, user_id, chat_id) \ + \VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + trWithId + pure tid + +getTrace :: SQL.Connection -> Text -> IO (Maybe TraceRecord) +getTrace conn tid = do + results <- + SQL.query + conn + "SELECT id, created_at, tool_name, input, output, duration_ms, user_id, chat_id \ + \FROM tool_traces WHERE id = ?" + (SQL.Only tid) + pure (listToMaybe results) + +cleanupOldTraces :: SQL.Connection -> IO Int +cleanupOldTraces conn = do + SQL.execute_ + conn + "DELETE FROM tool_traces WHERE created_at < datetime('now', '-7 days')" + SQL.changes conn diff --git a/Omni/Ava/Web.hs b/Omni/Ava/Web.hs new file mode 100644 index 0000000..86a8280 --- /dev/null +++ b/Omni/Ava/Web.hs @@ -0,0 +1,84 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Web server for Ava trace viewer. +-- +-- Serves the trace viewer UI for debugging tool executions. +-- +-- : out omni-ava-web +-- : dep warp +-- : dep wai +-- : dep http-types +-- : dep aeson +-- : dep text +-- : dep bytestring +-- : dep sqlite-simple +module Omni.Ava.Web + ( startWebServer, + app, + defaultPort, + ) +where + +import Alpha +import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Lazy as LBS +import qualified Data.Text as Text +import qualified Data.Text.Encoding as Encoding +import qualified Data.Text.IO as TextIO +import qualified Database.SQLite.Simple as SQL +import qualified Network.HTTP.Types as HTTP +import qualified Network.Wai as Wai +import qualified Network.Wai.Handler.Warp as Warp +import qualified Omni.Ava.Trace as Trace +import qualified System.Environment as Environment + +defaultPort :: Int +defaultPort = 8079 + +startWebServer :: Int -> FilePath -> IO () +startWebServer port dbPath = do + putText <| "Starting Ava web server on port " <> tshow port + Warp.run port (app dbPath) + +app :: FilePath -> Wai.Application +app dbPath request respond = do + case Wai.pathInfo request of + ["trace", tid] -> serveTracePage dbPath tid request respond + ["api", "trace", tid] -> serveTraceJson dbPath tid request respond + ["health"] -> respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "text/plain")] "ok" + _ -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "text/plain")] "Not found" + +serveTracePage :: FilePath -> Text -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived +serveTracePage dbPath tid _req respond = do + SQL.withConnection dbPath <| \conn -> do + maybeRec <- Trace.getTrace conn tid + case maybeRec of + Nothing -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "text/plain")] "Trace not found" + Just rec -> do + html <- renderTraceHtml rec + respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "text/html; charset=utf-8")] (LBS.fromStrict <| Encoding.encodeUtf8 html) + +serveTraceJson :: FilePath -> Text -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived +serveTraceJson dbPath tid _req respond = do + SQL.withConnection dbPath <| \conn -> do + maybeRec <- Trace.getTrace conn tid + case maybeRec of + Nothing -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "application/json")] "{\"error\":\"not found\"}" + Just rec -> do + let json = Aeson.encode rec + respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "application/json")] json + +renderTraceHtml :: Trace.TraceRecord -> IO Text +renderTraceHtml rec = do + coderoot <- Environment.getEnv "CODEROOT" + let templatePath = coderoot <> "/Omni/Ava/Web/trace.html" + template <- TextIO.readFile templatePath + pure + <| Text.replace "{{trace_id}}" (Trace.trcId rec) + <| Text.replace "{{tool_name}}" (Trace.trcToolName rec) + <| Text.replace "{{created_at}}" (Trace.trcCreatedAt rec) + <| Text.replace "{{duration_ms}}" (tshow <| Trace.trcDurationMs rec) + <| Text.replace "{{input_json}}" (Trace.trcInput rec) + <| Text.replace "{{output_json}}" (Trace.trcOutput rec) + <| template diff --git a/Omni/Ava/Web/trace.html b/Omni/Ava/Web/trace.html new file mode 100644 index 0000000..ce990a4 --- /dev/null +++ b/Omni/Ava/Web/trace.html @@ -0,0 +1,71 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Trace: {{tool_name}}</title> + <style> + :root { --bg: #1a1a2e; --fg: #eee; --accent: #4a9eff; --code-bg: #0d0d1a; } + @media (prefers-color-scheme: light) { + :root { --bg: #fff; --fg: #222; --accent: #0066cc; --code-bg: #f5f5f5; } + } + * { box-sizing: border-box; } + body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); padding: 1rem; margin: 0; max-width: 100%; } + h1 { font-size: 1.25rem; margin: 0 0 0.5rem; } + .meta { font-size: 0.875rem; opacity: 0.7; margin-bottom: 1rem; } + .section { margin: 1rem 0; border: 1px solid var(--accent); border-radius: 8px; overflow: hidden; } + .section-header { padding: 0.75rem 1rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; background: var(--code-bg); } + .section-header::before { content: '▼'; margin-right: 0.5rem; transition: transform 0.2s; } + .section.collapsed .section-header::before { transform: rotate(-90deg); } + .section-content { padding: 1rem; display: block; overflow-x: auto; background: var(--code-bg); } + .section.collapsed .section-content { display: none; } + pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 0.875rem; font-family: ui-monospace, monospace; } + .copy-btn { background: var(--accent); color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; } + .copy-btn:hover { opacity: 0.8; } + .footer { font-size: 0.75rem; opacity: 0.5; margin-top: 2rem; text-align: center; } + </style> +</head> +<body> + <h1>{{tool_name}}</h1> + <p class="meta">{{created_at}} · {{duration_ms}}ms</p> + + <div class="section" id="input-section"> + <div class="section-header"> + Input + <button class="copy-btn" data-target="input-content">Copy</button> + </div> + <div class="section-content" id="input-content"><pre>{{input_json}}</pre></div> + </div> + + <div class="section" id="output-section"> + <div class="section-header"> + Output + <button class="copy-btn" data-target="output-content">Copy</button> + </div> + <div class="section-content" id="output-content"><pre>{{output_json}}</pre></div> + </div> + + <p class="footer">Trace ID: {{trace_id}}</p> + + <script> + document.querySelectorAll('.section-header').forEach(header => { + header.addEventListener('click', (e) => { + if (e.target.classList.contains('copy-btn')) return; + header.parentElement.classList.toggle('collapsed'); + }); + }); + + document.querySelectorAll('.copy-btn').forEach(btn => { + btn.addEventListener('click', () => { + const targetId = btn.getAttribute('data-target'); + const text = document.getElementById(targetId).querySelector('pre').textContent; + navigator.clipboard.writeText(text).then(() => { + const orig = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(() => btn.textContent = orig, 1500); + }); + }); + }); + </script> +</body> +</html> |
