From fcb8629182fa1552e4a840ccd4ec0aa2b8042cc0 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Fri, 19 Dec 2025 21:54:54 -0500 Subject: 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 --- Omni/Ava/Trace.hs | 148 ++++++++++++++++++++++++++++++++++++++++++++++++ Omni/Ava/Web.hs | 84 +++++++++++++++++++++++++++ Omni/Ava/Web/trace.html | 71 +++++++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 Omni/Ava/Trace.hs create mode 100644 Omni/Ava/Web.hs create mode 100644 Omni/Ava/Web/trace.html (limited to 'Omni/Ava') 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..: "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 + +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) 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 @@ + + + + + + Trace: {{tool_name}} + + + +

{{tool_name}}

+

{{created_at}} · {{duration_ms}}ms

+ +
+
+ Input + +
+
{{input_json}}
+
+ +
+
+ Output + +
+
{{output_json}}
+
+ + + + + + -- cgit v1.2.3