<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vedas Kudalkar</title>
    <description>The latest articles on DEV Community by Vedas Kudalkar (@axololtos_30).</description>
    <link>https://dev.to/axololtos_30</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1956592%2Fd53bd446-1df5-4fdd-a48c-d65b0fb19603.jpg</url>
      <title>DEV Community: Vedas Kudalkar</title>
      <link>https://dev.to/axololtos_30</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/axololtos_30"/>
    <language>en</language>
    <item>
      <title>Cutting LLM Calls in Incident Triage: Sliding Windows, Log Reduction, and a pgvector Cache</title>
      <dc:creator>Vedas Kudalkar</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:03:27 +0000</pubDate>
      <link>https://dev.to/axololtos_30/cutting-llm-calls-in-incident-triage-sliding-windows-log-reduction-and-a-pgvector-cache-6hk</link>
      <guid>https://dev.to/axololtos_30/cutting-llm-calls-in-incident-triage-sliding-windows-log-reduction-and-a-pgvector-cache-6hk</guid>
      <description>&lt;p&gt;&lt;em&gt;A Kafka-to-WebSocket pipeline where the model is the last resort, not the first step.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The obvious way to add an LLM to log monitoring is to send logs to a model and ask what is wrong. That breaks quickly. Most log lines are not errors. During an outage, the same error repeats thousands of times with only timestamps, UUIDs and counters changing. A raw stack trace with framework frames is mostly noise. And the same database timeout will show up on &lt;code&gt;orders&lt;/code&gt; today and &lt;code&gt;inventory&lt;/code&gt; tomorrow, so you pay for the same diagnosis again.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/axololtos/telemetry-incident-dashboard" rel="noopener noreferrer"&gt;telemetry-incident-dashboard&lt;/a&gt; around one rule: every step before the model must be cheaper than the model, and must be able to stop the pipeline.&lt;/p&gt;

&lt;p&gt;It is a working prototype. I'll say clearly later what has and hasn't been exercised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Apps / log shipper] --&amp;gt; K[Kafka topic app-logs]
    K --&amp;gt; C[Spring Boot consumer&amp;lt;br/&amp;gt;virtual threads]
    C --&amp;gt;|every log| W[STOMP /topic/logs]
    C --&amp;gt;|ERROR only| D[Per-service sliding window&amp;lt;br/&amp;gt;10+ errors in 60s]
    D --&amp;gt;|below threshold| X[Stop: no tokens]
    D --&amp;gt;|burst| R[LogPayloadReducer]
    R --&amp;gt; E{Exact fingerprint&amp;lt;br/&amp;gt;in cache?}
    E --&amp;gt;|yes| H[CACHE_EXACT]
    E --&amp;gt;|no| V{pgvector cosine&amp;lt;br/&amp;gt;&amp;gt;= 0.90?}
    V --&amp;gt;|yes| S[CACHE_SEMANTIC]
    V --&amp;gt;|no| L[LLM call via LangChain4j]
    L --&amp;gt; P[(PostgreSQL + pgvector&amp;lt;br/&amp;gt;diagnosis_cache)]
    H --&amp;gt; I[(incidents hypertable)]
    S --&amp;gt; I
    L --&amp;gt; I
    I --&amp;gt; A2[STOMP /topic/alerts]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Stack: Java 21, Spring Boot 3.3, Apache Kafka (KRaft), PostgreSQL with TimescaleDB and pgvector, LangChain4j with OpenAI (&lt;code&gt;gpt-4o-mini&lt;/code&gt; and &lt;code&gt;text-embedding-3-small&lt;/code&gt; by default), and a React + Vite dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: detect bursts without a model
&lt;/h2&gt;

&lt;p&gt;Each service gets its own &lt;code&gt;ErrorWindow&lt;/code&gt;. It holds the errors from the last 60 seconds, fires when the count reaches the threshold (default 10), and then stays quiet for a cooldown (default 120 seconds).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LogEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LogEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;nowMs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;windowMs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                   &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;cooldownMs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxBuffered&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addLast&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stamped&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nowMs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;nowMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;peekFirst&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;arrivalMs&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;windowMs&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pollFirst&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxBuffered&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pollFirst&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;nowMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastAlertMs&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cooldownMs&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;lastAlertMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nowMs&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;Stamped:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Info, debug and warning logs never get here. They are counted and streamed to the dashboard, capped at 200 non-error events per second. A malformed Kafka record is counted and dropped. The listener never rethrows, so one bad record can't stall a partition.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv0675gs4sydzxlnx530u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv0675gs4sydzxlnx530u.png" alt="Code Screenshot - LogAnalysisEngine.resolve" width="799" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Visual instruction: Capture this exact code section in VS Code using the OneDark-Pro theme. Use a readable font size and crop unnecessary editor UI.&lt;/p&gt;

&lt;p&gt;The cooldown matters. Without it, a long outage would trigger analysis on every incoming error once the window is full.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: shrink the burst
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LogPayloadReducer&lt;/code&gt; turns a burst into a short context string:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;strips ANSI codes, ISO timestamps, UUIDs and hex addresses&lt;/li&gt;
&lt;li&gt;drops framework frames (&lt;code&gt;java.base/&lt;/code&gt;, &lt;code&gt;jdk.internal.&lt;/code&gt;, Tomcat, Spring AOP and CGLIB, JUnit)&lt;/li&gt;
&lt;li&gt;keeps the top 12 stack lines and notes how many were cut&lt;/li&gt;
&lt;li&gt;groups identical errors by fingerprint and prints them as &lt;code&gt;xN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;caps the whole thing at 2,000 characters and 5 distinct errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fingerprint is a SHA-256 of the text after volatile parts are normalised. Numbers become &lt;code&gt;&amp;lt;n&amp;gt;&lt;/code&gt;, so &lt;code&gt;Timeout after 3001ms&lt;/code&gt; and &lt;code&gt;Timeout after 9999ms&lt;/code&gt; hash the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;NUMBER&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scrub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;replaceAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;n&amp;gt;"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the 15-error connection-pool burst in &lt;code&gt;sample-data/02-db-timeout-burst.ndjson&lt;/code&gt;, the README reports the model input going from 8,415 characters (about 2,100 tokens) to 506 characters (about 127 tokens). That is one synthetic burst and the token count is a four-characters-per-token estimate, so treat 94% as an illustration, not a benchmark.&lt;/p&gt;

&lt;p&gt;The reduced output looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service=orders errors=15 distinct=1
[1] x15 ERROR HikariPool-1 - Connection is not available, request timed out after 30001ms
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30001ms
    at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
    at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
    at com.shop.orders.Repository.save(Repository.java:48)
    at com.shop.orders.Service.handle(Service.java:71)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: a two-level cache
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LogAnalysisEngine.resolve&lt;/code&gt; goes cheapest first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact match.&lt;/strong&gt; One &lt;code&gt;UPDATE ... RETURNING&lt;/code&gt; both bumps the hit counter and fetches the stored diagnosis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queryForList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"UPDATE diagnosis_cache SET hits = hits + 1 WHERE fingerprint = ? RETURNING diagnosis"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fingerprint&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Semantic match.&lt;/strong&gt; If there's no exact hit, embed the context and ask pgvector for the nearest row. A cosine similarity of 0.90 or more counts as a hit. This is what lets a database timeout on &lt;code&gt;inventory&lt;/code&gt; reuse the diagnosis made for &lt;code&gt;orders&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="no"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diagnosis&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;?::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="no"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;
&lt;span class="no"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;diagnosis_cache&lt;/span&gt;
&lt;span class="no"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="no"&gt;IS&lt;/span&gt; &lt;span class="no"&gt;NOT&lt;/span&gt; &lt;span class="no"&gt;NULL&lt;/span&gt;
&lt;span class="no"&gt;ORDER&lt;/span&gt; &lt;span class="no"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;?::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;
&lt;span class="no"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table uses an HNSW index with &lt;code&gt;vector_cosine_ops&lt;/code&gt;. The schema is 1536 dimensions to match &lt;code&gt;text-embedding-3-small&lt;/code&gt;. If you change the embedding model, you change &lt;code&gt;docker/init.sql&lt;/code&gt; and &lt;code&gt;EMBEDDING_DIMENSIONS&lt;/code&gt; together. The engine checks the returned vector length and skips the vector cache on a mismatch instead of failing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM call.&lt;/strong&gt; Only on a miss. The prompt asks for under 150 words in three headings (root cause with confidence, impact, fix) and says not to invent details. The answer and its embedding are written back, so the next occurrence is free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjkw08uy49okj4a23cfzc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjkw08uy49okj4a23cfzc.png" alt="Code Screenshot - LogAnalysisEngine.resolve" width="800" height="796"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Visual instruction: Capture this exact code section in VS Code using the OneDark-Pro theme. Use a readable font size and crop unnecessary editor UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engineering decisions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Single-flight for identical bursts.&lt;/strong&gt; Two services can hit the same failure at once. &lt;code&gt;diagnoseOnce&lt;/code&gt; keeps a &lt;code&gt;ConcurrentMap&amp;lt;String, CompletableFuture&amp;lt;Diagnosis&amp;gt;&amp;gt;&lt;/code&gt; keyed by fingerprint. The first caller does the work; the rest &lt;code&gt;join()&lt;/code&gt; its future. That avoids paying for duplicate LLM calls in the gap before the first answer lands in the cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never throw from the pipeline.&lt;/strong&gt; If the cache query, the embedding call, the database or the LLM fails, the incident is still recorded with source &lt;code&gt;FALLBACK&lt;/code&gt; and the raw condensed telemetry attached. A flaky dependency degrades the diagnosis, not the stream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtual threads.&lt;/strong&gt; Each Kafka consumer loop and each analysis task runs on a Java 21 virtual thread. Analysis is blocking I/O (HTTP to a model, JDBC), which is the case virtual threads fit. The analysis is also submitted off the consumer thread so a slow model call can't hold up log ingestion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timescale for history.&lt;/strong&gt; Cached diagnoses live in &lt;code&gt;diagnosis_cache&lt;/code&gt;. Every incident, including cache hits, goes into an &lt;code&gt;incidents&lt;/code&gt; hypertable with its source, similarity and tokens used, so you can see how often the model is actually needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;There are two unit test classes, five tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ErrorWindowTest&lt;/code&gt;: fires at the threshold, respects cooldown, and expires old errors from the window.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LogPayloadReducerTest&lt;/code&gt;: truncates stacks to 12 lines plus a marker, collapses 200 repeated errors to &lt;code&gt;x200&lt;/code&gt; under the 2,000-character budget, and gives two events with different numbers and UUIDs the same fingerprint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nine NDJSON scenarios in &lt;code&gt;sample-data/&lt;/code&gt; cover normal traffic, bursts (DB timeout, NPE, OOM, upstream 502, JWT failures), a similar failure on a second service, malformed lines, and a below-threshold case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;I'd rather list these than have you find them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The successful LLM path (real diagnosis written to the cache and then served as a hit) has &lt;strong&gt;not&lt;/strong&gt; been run against a funded OpenAI account. The fallback path, reducer, window logic and the dashboard demo have been run.&lt;/li&gt;
&lt;li&gt;The dashboard's live mode hasn't been tested against the backend.&lt;/li&gt;
&lt;li&gt;Kafka offsets auto-commit, so a crash mid-analysis can lose that burst.&lt;/li&gt;
&lt;li&gt;No auth on the WebSocket endpoint. No cache eviction. Single Kafka broker.&lt;/li&gt;
&lt;li&gt;No throughput or latency benchmarks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env            &lt;span class="c"&gt;# optionally set OPENAI_API_KEY&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;            &lt;span class="c"&gt;# Kafka (KRaft) + TimescaleDB with pgvector&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .env&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a
mvn spring-boot:run             &lt;span class="c"&gt;# http://localhost:8080, STOMP at /ws&lt;/span&gt;

&lt;span class="nb"&gt;cd &lt;/span&gt;dashboard &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run dev   &lt;span class="c"&gt;# http://localhost:5173&lt;/span&gt;

scripts/send-sample-logs.sh sample-data/02-db-timeout-burst.ndjson
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without an API key the app still starts, and bursts are stored as &lt;code&gt;FALLBACK&lt;/code&gt;. There is also a &lt;a href="https://telemetry-dashboard-mu-wheat.vercel.app" rel="noopener noreferrer"&gt;browser-only demo&lt;/a&gt; that simulates the same rules with hand-written diagnoses and is labeled as a simulation. Click "Play the story" to see an LLM call, a below-threshold burst, a similar-error hit and an exact hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Not implemented yet, but the obvious gaps: manual offset commits after analysis, cache eviction, authentication on the WebSocket, and a real run of the LLM path with measured cache hit rates.&lt;/p&gt;




&lt;p&gt;Code is on GitHub: &lt;strong&gt;&lt;a href="https://github.com/axololtos/telemetry-incident-dashboard" rel="noopener noreferrer"&gt;axololtos/telemetry-incident-dashboard&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it's useful, a star helps. Issues and PRs are welcome (there's a CONTRIBUTING guide). Follow me here on DEV for more write-ups like this.&lt;/p&gt;

</description>
      <category>java</category>
      <category>kafka</category>
      <category>postgres</category>
      <category>ai</category>
    </item>
    <item>
      <title>Ofrenda: a keeper for stories that runs entirely on your laptop</title>
      <dc:creator>Vedas Kudalkar</dc:creator>
      <pubDate>Sat, 31 Oct 2026 06:56:16 +0000</pubDate>
      <link>https://dev.to/axololtos_30/ofrenda-a-keeper-for-stories-that-runs-entirely-on-your-laptop-4lee</link>
      <guid>https://dev.to/axololtos_30/ofrenda-a-keeper-for-stories-that-runs-entirely-on-your-laptop-4lee</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Ofrenda is a command-line tool. You give it a story about someone, and it keeps it. Later you ask a question and it answers from what you told it, and from nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python ofrenda.py remember &lt;span class="s2"&gt;"Abuela"&lt;/span&gt; &lt;span class="s2"&gt;"She burned the first tortilla on purpose, for the house. She sang boleros while washing dishes, always the same one, Sabor a Mi. On Sundays she made caldo de res and nobody was allowed to start eating before she sat down."&lt;/span&gt;
&lt;span class="go"&gt;Lit 4 candle(s) for Abuela.

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python ofrenda.py ask &lt;span class="s2"&gt;"Abuela"&lt;/span&gt; &lt;span class="s2"&gt;"What did she cook on Sundays?"&lt;/span&gt;
&lt;span class="go"&gt;On Sundays, she made caldo de res.

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python ofrenda.py ask &lt;span class="s2"&gt;"Abuela"&lt;/span&gt; &lt;span class="s2"&gt;"Where was she born?"&lt;/span&gt;
&lt;span class="go"&gt;The ofrenda does not hold that memory yet. Would you like to add it?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last answer is the one I care about. The story never said where she was born, so the tool says it doesn't know. It doesn't guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I read the theme
&lt;/h2&gt;

&lt;p&gt;An ofrenda is the altar families build for the dead: photos, marigolds, a favorite dish, a song. The point is that someone is remembered in specific detail. A ghost in a machine sounded to me like the opposite risk. A chatbot that speaks as your grandmother is eerie, and it will say things she never said.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhj3e7yguk7iosu3avxqp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhj3e7yguk7iosu3avxqp.png" alt="keeper’s system prompt and ask() wrapped in Sentry agent and tool spans" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the assistant here is called the keeper, and the rule is written into its system prompt: it reads memories aloud, it never speaks as the person, and if the memories don't cover the question it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;A story goes through a short pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Gemma splits the story into separate memories, returned as JSON.&lt;/li&gt;
&lt;li&gt;Each memory is embedded and stored in a local SQLite file.&lt;/li&gt;
&lt;li&gt;When you ask something, the question is embedded, the closest memories for that person are retrieved, and Gemma answers from those only.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One story above became four stored memories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- She burned the first tortilla on purpose for the house.            ["cooking"]
- She sang boleros while washing dishes, always singing Sabor a Mi.  ["song"]
- On Sundays, she made caldo de res.                                 ["food"]
- Nobody was allowed to start eating before she sat down.            ["ritual"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search is a plain cosine-similarity scan filtered by person. That is enough for a family's worth of stories, and it means one person's memories can't leak into another's answers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnlrvg6awijdxe51kw9da.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnlrvg6awijdxe51kw9da.png" alt="The memory search function: filtered by person, ranked by cosine similarity" width="800" height="129"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The three pieces I used
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gemma, through Ollama.&lt;/strong&gt; Both the splitting and the answering run on a local Gemma model. For my demo run I used &lt;code&gt;gemma4:e2b&lt;/code&gt;, and the default in the repo is &lt;code&gt;gemma3:4b&lt;/code&gt;. Any tag Ollama can serve works. Embeddings come from &lt;code&gt;nomic-embed-text&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwschvv6qdivb22ow0725.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwschvv6qdivb22ow0725.png" alt="llm.chat(), one local Gemma call traced with model name and token counts" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporal.&lt;/strong&gt; Saving a story is two steps that depend on a local model, and local models are slow to load and sometimes fail. I wrapped the steps in a Temporal workflow with a retry policy. On my first real run, Ollama returned an HTTP 500 while the model was still loading. Temporal retried the step, and the command finished with all four memories saved. Without the workflow I would have lost the story and seen a stack trace. The activity also falls back to storing the original text as one memory if Gemma returns invalid JSON, so a bad parse can't drop a story.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxqoa7o4vmmicpinw7yex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxqoa7o4vmmicpinw7yex.png" alt="Gemma extraction with the never-lose-the-story fallback, and the retrying workflow" width="800" height="585"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentry Agent Tracing.&lt;/strong&gt; Every &lt;code&gt;ask&lt;/code&gt; is traced with Sentry's AI span names: an agent span for the keeper, a tool span for the memory lookup, and chat and embedding spans with model name and token counts. Tracing only turns on if you set a &lt;code&gt;SENTRY_DSN&lt;/code&gt;. One decision here mattered to me: prompts and answers are not attached to traces unless you set &lt;code&gt;OFRENDA_TRACE_CONTENT=1&lt;/code&gt;. Family stories shouldn't end up in a monitoring dashboard by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why open models matter for this
&lt;/h2&gt;

&lt;p&gt;Stories about someone who died are about as private as data gets. With this setup the model, the embeddings and the database all live on your machine. It works with the internet off. The memories are one &lt;code&gt;.db&lt;/code&gt; file you own, so there's no service that can change its pricing or shut down and take them with it. I couldn't build this on a hosted API and feel good about it, and that is the real reason I used open models, not cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;It's text only, with no audio recording or transcription. The database isn't encrypted. A small local model can still misread a story, so it's worth checking that what was stored matches what you meant. Search is a linear scan, which is fine for a family and wrong for a million memories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Live demo (recorded session): &lt;a href="https://axololtos.github.io/ofrenda/" rel="noopener noreferrer"&gt;axololtos/ofrenda_demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ofrenda runs on a local model through Ollama, so it can't be hosted as a live app without sending people's stories to a server, which is the thing I built it to avoid. That page is a preview: a replay of a real run from my laptop, with the same commands and the same output. To use the real thing, run it locally with the steps below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Code and setup steps are here: &lt;a href="https://github.com/axololtos/ofrenda" rel="noopener noreferrer"&gt;github.com/axololtos/ofrenda&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You need Python, Ollama and the Temporal dev server. The README has the exact commands.&lt;/p&gt;

&lt;p&gt;Prize categories: Best Use of Gemma, Best Use of Temporal, Best Use of Sentry Agent Tracing.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>hacktoberfest</category>
      <category>devchallenge</category>
    </item>
  </channel>
</rss>
