OpenTelemetry on Spring Boot 3: when logs say OK and traces show the problem

Jaeger trace showing DB fan-out in the N+1 scenario

There’s a question I’ve asked myself many times while debugging backend systems: did the request take long because the DB was slow, because the downstream kept us waiting, or because some internal loop fired 60 queries to fetch 60 records? The log says duration_ms=340 and status=200. That’s it. You start guessing.

That moment of uncertainty is where this lab came from. Not to measure OpenTelemetry overhead, not to compare Jaeger against Tempo, but to answer something more concrete: what signals do you lose when you only have good logs, and what shows up when you add a trace?

The repo is at github.com/JuanTorchia/opentelemetry-spring-boot-lab, commit c12ea4e848dc431c8bbd324318399172302fe053, tag editorial-final-diagnosis-comparison-v2.

The setup: a lab that produces evidence, not benchmarks

The stack is Spring Boot 3.5.7, Java 21, PostgreSQL 16, OpenTelemetry API 1.43.0, OpenTelemetry Java Agent 2.9.0, and Jaeger all-in-one. Everything starts with Docker Compose. To reproduce it:

# Quick smoke test with small dataset (1k tasks)
.scriptsrun-lab.ps1 -Mode smoke -Size small

# Full editorial run (50k tasks, 200 requests, warmup 20, concurrency 8)
.scriptsrun-lab.ps1 -Mode editorial -Size editorial -Runs 3 -Requests 200 -Warmup 20 -Concurrency 8

The runner starts Compose, downloads the agent into tools/, packages the jar, seeds Postgres with synthetic tables (organizations, users, projects, tasks, comments), runs the scenarios, queries Jaeger by traceId, and regenerates the reports in results/.

Jaeger was chosen for local simplicity: one image, web UI, REST API to query traces by traceId. Tempo is also valid, but needs more moving parts for a local editorial demo. This is not a production stack recommendation.

The editorial dataset has 50,000 tasks. The small dataset has 1,000. That difference matters so the N+1 produces visible fan-out rather than a microsecond gap that disappears into noise.

The instrumentation decision I care about most

The pom.xml has opentelemetry-api as a compile dependency, but the agent arrives at runtime. That means HTTP server, HTTP client, and JDBC are instrumented automatically without touching business code.

Manual spans are used only for business stages that the agent can’t infer:

// LabService.java — manual span to mark business intent
Span span = tracer.spanBuilder("business.n_plus_one.load_tasks_then_comments").startSpan();
try (var ignored = span.makeCurrent()) {
    // first fetches tasks, then runs one query per task
    List<Map<String, Object>> tasks = jdbcTemplate.queryForList(
        "select t.id, t.title, u.display_name as assignee from tasks t "
        + "join users u on u.id = t.assignee_id order by t.id limit ?",
        limit);
    for (Map<String, Object> task : tasks) {
        Long taskId = ((Number) task.get("id")).longValue();
        // this query repeats per task → fan-out
        Integer comments = jdbcTemplate.queryForObject(
            "select count(*) from comments where task_id = ?",
            Integer.class, taskId);
        // ...
    }
    span.setAttribute("lab.n_plus_one.expected_extra_queries", enriched.size());
} finally {
    span.end();
}

That mix is more honest for the post: auto-instrumentation for infrastructure, manual spans to explain intent. If I had used only manual spans, the lab would require observability-specific code in every layer. If I had relied only on the agent, business spans would be invisible.

The logback-spring.xml injects traceId and spanId into every log line:

<!-- logback-spring.xml -->
<pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level traceId=%X{trace_id:-none} spanId=%X{span_id:-none} %logger{36} - %msg%n</pattern>

That’s what connects both worlds. A log with traceId lets you jump directly to the trace in Jaeger. Without it, logs and traces are islands.

The Matrix That Summarizes The Diagnosis

Scenario p95 Avg spans Avg DB spans Error spans/request Defensible diagnosis
baseline 55 ms 3.04 1.04 0 Healthy request, no weird story.
optimized 59 ms 3.04 1.04 0 Same functional shape, no DB fan-out.
n-plus-one 209 ms 63.38 61.38 0 DB fan-out visible inside one request.
downstream-slow 374 ms 4 0 0 Time concentrates in downstream.
mixed 395 ms 7.57 1.57 0 DB, downstream, and transformation compete.
partial-error 184 ms 6.27 1.27 3 Downstream error inside a partial response.

This table is not trying to crown a tool. It summarizes which signals are available for diagnosis. The strong point is not that one number is universal: it is that N+1 leaves a very different shape than the optimized case, and that shape does not appear in a flat log unless you enable SQL debug.

What the six scenarios reveal

The lab has six endpoints: baseline, n-plus-one, optimized, downstream-slow, mixed, and partial-error. Each produces different signals that the runner consolidates into results/comparison.md and results/diagnosis-comparison.md.

The finding I most want to defend:

N+1 vs optimized: both return the same response shape. The log for both says status=200. The difference lives in the trace: n-plus-one generates an average of 63.38 spans per request in the editorial run; optimized generates 3.04. That’s not a universal performance claim — it’s a diagnostic signal. With only logs and no SQL debug enabled, the difference is ambiguous. With the trace, DB fan-out is visible without extra configuration.

Downstream-slow: p95 sits at 374 ms, very close to the configured 300 ms delay. Logs show total duration and traceId. What they don’t show is where that time went: was it DB? was it the downstream? was it in-memory transformation? The trace separates it: the downstream HTTP client span dominates the hierarchy. The local DB appears as a secondary span with low duration.

Mixed: this is where flat logs fail the most. Three stages compete (DB, downstream, transformation) and none is obviously dominant. p95 reaches 395 ms. The trace shows the temporal distribution per stage. The log just says it was slow.

Partial-error: the endpoint responds with HTTP 206 (partial content). The log records traceId, status, and error type. The trace goes further: the downstream span is marked with error, nested under a request that technically responded. Logs and trace don’t replace each other here — they complement. The log alerts and lets you correlate. The trace places the error in the causal hierarchy.

The Screenshot That Changed The Diagnosis

In Jaeger, n-plus-one does not look like a request that is merely a bit slower. It looks like a request with DB fan-out: many repeated spans under the same business operation.

Jaeger trace showing DB fan-out in the N+1 scenario

The optimized case, on the other hand, keeps a compact shape. I do not need to read the code to suspect that the previous case was not “Postgres is slow” in the abstract, but the query shape.

Jaeger trace for the optimized scenario

The partial-error case matters for another reason: the request can respond, while the downstream span is marked as errored. That nuance is exactly where logs and traces complement each other: the log alerts, the trace locates.

Jaeger trace with partial downstream error marked

The honest limits of the metrics

The *_vs_root_pct fields in results/diagnosis-comparison.md are cumulative percentages of span durations exported by Jaeger. They can exceed 100% when there are nested spans, client/server pairs, or overlap. The duration_denominator_type field indicates what was used as the denominator: root_span, http_request_span, or largest_observed_span if the trace was ambiguous.

These are not overhead numbers. They are not an exact distribution of real request time. They are cumulative diagnostic signals. Treating them like CPU percentages would be a misread that this lab doesn’t try to encourage.

Similarly, diagnosis_confidence_* is an editorial classification coded in ScenarioDiagnosis.java, not an automatically measured metric. For N+1, diagnosisConfidenceLogs is low and diagnosisConfidenceTrace is high. That reflects the fact that without SQL debug, the log is ambiguous. It’s not a universal benchmark of which tool is better.

My position: what I accept and what I don’t buy

I accept that OpenTelemetry with the Java Agent is a reasonable way to add structural visibility to a Spring Boot 3 app without polluting business code. JDBC and HTTP client auto-instrumentation works well for common scenarios.

I don’t buy the narrative that traces replace logs. The lab’s RequestCompletionLoggingFilter is a Servlet filter that records every completed request with scenario, method, path, status, and duration. Those logs are operationally useful even when Jaeger is unavailable. The traceId in the log is the bridge, not the replacement.

I also don’t buy that Jaeger is the only valid option. It was chosen because it starts with one image and has a ready web UI. Tempo, Zipkin, or any OTLP-compatible backend would solve the same problem in this context.

The honest trade-off is this: auto-instrumentation reduces accidental work but adds an agent on the classpath that exports data in the background. In a local lab that’s trivial. In production, agent overhead depends on load, exporter configuration, and sampling. This lab doesn’t measure that, and claiming otherwise would be misleading.

What to do with this

If you already have structured logs in production with traceId and spanId, the next step isn’t replacing anything. It’s adding a trace backend and connecting both worlds. The lab shows that Spring Boot 3 auto-instrumentation with the Java Agent is enough for common scenarios, and that manual spans only make sense when you want to name business intent that the agent can’t infer.

If you’re evaluating whether the effort is worth it: the case where it’s most clearly justified isn’t the healthy baseline. It’s the mixed scenario or the N+1, where logs give you a number and the trace gives you a shape. The difference between guessing and diagnosing.

After this lab, my rule is simple: logs tell you what happened; traces help you understand how it happened. If the flat log only gives you total duration, you do not have an explanation yet. You have a clue.

This article was originally published on juanchi.dev

OpenTelemetry en Spring Boot 3: cuando el log dice OK y el trace muestra el problema

Trace de Jaeger mostrando fan-out DB en el escenario N+1

Hay una pregunta que me hice muchas veces debuggeando sistemas backend: ¿la request tardó porque la DB fue lenta, porque el downstream nos clavó, o porque algún loop interno disparó 60 queries para traer 60 registros? El log dice duration_ms=340 y status=200. Eso es todo. Empezás a adivinar.

Ese momento de incertidumbre fue el origen de este laboratorio. No para medir overhead de OpenTelemetry, no para comparar Jaeger contra Tempo, sino para responder algo más concreto: ¿qué señales perdés cuando solo tenés logs buenos, y qué aparece cuando sumás un trace?

El repo está en github.com/JuanTorchia/opentelemetry-spring-boot-lab, commit c12ea4e848dc431c8bbd324318399172302fe053, tag editorial-final-diagnosis-comparison-v2.

El setup: un laboratorio que produce evidencia, no benchmarks

El stack es Spring Boot 3.5.7, Java 21, PostgreSQL 16, OpenTelemetry API 1.43.0, OpenTelemetry Java Agent 2.9.0 y Jaeger all-in-one. Todo levanta con Docker Compose. Para reproducirlo:

# Smoke rápido con dataset pequeño (1k tasks)
.scriptsrun-lab.ps1 -Mode smoke -Size small

# Corrida editorial completa (50k tasks, 200 requests, warmup 20, concurrencia 8)
.scriptsrun-lab.ps1 -Mode editorial -Size editorial -Runs 3 -Requests 200 -Warmup 20 -Concurrency 8

El runner levanta Compose, descarga el agente en tools/, empaqueta el jar, seedea Postgres con tablas sintéticas (organizations, users, projects, tasks, comments), ejecuta los escenarios, consulta Jaeger por traceId y regenera los reportes en results/.

Jaeger fue elegido por simplicidad local: una imagen, UI web, API REST para consultar traces por traceId. Tempo también es válido, pero necesita más piezas para una demo editorial local. No es una recomendación de stack productivo.

El dataset editorial tiene 50.000 tasks. El small tiene 1.000. La diferencia importa para que el N+1 produzca fan-out visible y no una diferencia de microsegundos que desaparece en el ruido.

La decisión de instrumentación que más me importa

El pom.xml tiene opentelemetry-api como dependencia de compilación, pero el agente llega en runtime. Eso significa que HTTP server, HTTP client y JDBC se instrumentan automáticamente sin tocar el código de negocio.

Los spans manuales se usan solo para etapas de negocio que el agente no puede inferir:

// LabService.java — span manual para marcar intención de negocio
Span span = tracer.spanBuilder("business.n_plus_one.load_tasks_then_comments").startSpan();
try (var ignored = span.makeCurrent()) {
    // primero trae tasks, luego hace una query por cada una
    List<Map<String, Object>> tasks = jdbcTemplate.queryForList(
        "select t.id, t.title, u.display_name as assignee from tasks t "
        + "join users u on u.id = t.assignee_id order by t.id limit ?",
        limit);
    for (Map<String, Object> task : tasks) {
        Long taskId = ((Number) task.get("id")).longValue();
        // esta query se repite por cada task → fan-out
        Integer comments = jdbcTemplate.queryForObject(
            "select count(*) from comments where task_id = ?",
            Integer.class, taskId);
        // ...
    }
    span.setAttribute("lab.n_plus_one.expected_extra_queries", enriched.size());
} finally {
    span.end();
}

Esa mezcla es más honesta para el post: auto-instrumentación para infraestructura, spans manuales para explicar intención. Si hubiera usado solo spans manuales, el lab requeriría código específico de observabilidad en cada capa. Si hubiera confiado solo en el agente, los spans de negocio serían invisibles.

El logback-spring.xml inyecta traceId y spanId en cada línea de log:

<!-- logback-spring.xml -->
<pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level traceId=%X{trace_id:-none} spanId=%X{span_id:-none} %logger{36} - %msg%n</pattern>

Eso es lo que conecta ambos mundos. Un log con traceId te permite saltar directo al trace en Jaeger. Sin eso, logs y traces son islas.

La matriz que resume el diagnóstico

Escenario p95 Spans promedio DB spans promedio Error spans/request Diagnóstico defendible
baseline 55 ms 3,04 1,04 0 Request sana, sin historia rara.
optimized 59 ms 3,04 1,04 0 Misma forma funcional, sin fan-out DB.
n-plus-one 209 ms 63,38 61,38 0 Fan-out DB visible en una sola request.
downstream-slow 374 ms 4 0 0 El tiempo se concentra en downstream.
mixed 395 ms 7,57 1,57 0 DB, downstream y transformación compiten.
partial-error 184 ms 6,27 1,27 3 Error downstream dentro de una respuesta parcial.

Esta tabla no intenta coronar una herramienta. Resume qué señales quedan disponibles para diagnosticar. El dato fuerte no es que un número sea universal: es que el N+1 deja una forma muy distinta al caso optimizado, y esa forma no aparece en un log plano sin activar SQL debug.

Lo que revelan los seis escenarios

El laboratorio tiene seis endpoints: baseline, n-plus-one, optimized, downstream-slow, mixed y partial-error. Cada uno produce señales diferentes que el runner consolida en results/comparison.md y results/diagnosis-comparison.md.

El hallazgo que más me interesa defender:

N+1 vs optimized: ambos devuelven el mismo shape de respuesta. El log de ambos dice status=200. La diferencia está en el trace: n-plus-one genera un promedio de 63,38 spans por request en la corrida editorial; optimized genera 3,04. Eso no es un claim de performance universal, es una señal diagnóstica. Con solo los logs, sin activar SQL debug, la diferencia es ambigua. Con el trace, el fan-out DB es visible sin configuración extra.

Downstream-slow: el p95 está en 374 ms, muy cerca del delay configurado de 300 ms. Los logs muestran la duración total y el traceId. Lo que no muestran es dónde se fue ese tiempo: ¿fue DB? ¿fue el downstream? ¿fue transformación en memoria? El trace lo separa: el span HTTP client del downstream domina la jerarquía. La DB local aparece como span secundario de duración baja.

Mixed: aquí es donde los logs planos fallan más. Tres etapas compiten (DB, downstream, transformación) y ninguna es dominante de forma obvia. El p95 llega a 395 ms. El trace muestra la distribución temporal por etapa. El log solo dice que tardó.

Partial-error: el endpoint responde con HTTP 206 (partial content). El log registra el traceId, el status y el tipo de error. El trace va más lejos: el span del downstream está marcado con error, anidado bajo una request que técnicamente respondió. Logs y trace no se reemplazan acá, se complementan. El log avisa y permite correlacionar. El trace ubica el error en la jerarquía causal.

La captura que cambió el diagnóstico

En Jaeger, n-plus-one no se ve como una request apenas más lenta. Se ve como una request con fan-out DB: muchos spans repetidos bajo una misma operación de negocio.

Trace de Jaeger mostrando fan-out DB en el escenario N+1

El caso optimizado, en cambio, mantiene una forma compacta. No necesito mirar el código para sospechar que el problema del caso anterior no era “Postgres lento” en abstracto, sino el shape de queries.

Trace de Jaeger del escenario optimizado

El caso de error parcial también vale por otra razón: la request puede responder, pero el span del downstream queda marcado con error. Ese matiz es justo donde logs y traces se complementan: el log avisa, el trace ubica.

Trace de Jaeger con error parcial marcado en downstream

El límite honesto de las métricas

Los campos *_vs_root_pct en results/diagnosis-comparison.md son porcentajes acumulados de duración de spans exportados por Jaeger. Pueden superar el 100% cuando hay spans anidados, pares cliente/servidor o solapamiento. El campo duration_denominator_type indica qué se usó como denominador: root_span, http_request_span o largest_observed_span si la traza quedó ambigua.

No son overhead. No son distribución exacta del tiempo real de la request. Son señales diagnósticas acumuladas. Usarlas como si fueran porcentajes de CPU sería un error de interpretación que este lab no intenta fomentar.

De la misma forma, diagnosis_confidence_* es una clasificación editorial codificada en ScenarioDiagnosis.java, no una métrica medida automáticamente. Para N+1, diagnosisConfidenceLogs es low y diagnosisConfidenceTrace es high. Eso refleja que sin SQL debug, el log es ambiguo. No es un benchmark universal de qué herramienta es mejor.

Mi postura: qué acepto y qué no compro

Acepto que OpenTelemetry con el Java Agent es una forma razonable de agregar visibilidad estructural a una app Spring Boot 3 sin ensuciar el código de negocio. La auto-instrumentación de JDBC y HTTP client funciona bien para escenarios comunes.

No compro la narrativa de que los traces reemplazan los logs. El RequestCompletionLoggingFilter del lab es un filtro Servlet que registra cada request completada con escenario, método, path, status y duración. Esos logs son operativamente útiles aunque Jaeger no esté disponible. El traceId en el log es el puente, no el reemplazo.

Tampoco compro que Jaeger sea la única opción válida. Se eligió porque levanta con una imagen y tiene UI web lista. Tempo, Zipkin o cualquier backend compatible con OTLP resolverían el mismo problema en este contexto.

El trade-off honesto es este: la auto-instrumentación reduce trabajo accidental pero agrega un agente en el classpath que exporta datos en background. En un laboratorio local eso es trivial. En producción, el overhead del agente depende de la carga, la configuración del exporter y el sampling. Este lab no mide eso, y sería engañoso afirmar que sí.

Qué hacer con esto

Si ya tenés logs estructurados en producción con traceId y spanId, el paso siguiente no es reemplazar nada. Es agregar el backend de traces y conectar ambos mundos. El lab muestra que la auto-instrumentación de Spring Boot 3 con el Java Agent es suficiente para los escenarios comunes, y que los spans manuales tienen sentido solo cuando querés nombrar intención de negocio que el agente no puede inferir.

Si estás evaluando si vale la pena el esfuerzo: el caso donde más claramente lo justifica no es el baseline sano. Es el escenario mixto o el N+1, donde los logs te dan un número y el trace te da una forma. La diferencia entre adivinar y diagnosticar.

Después de este lab, mi regla queda así: logs para saber qué pasó; traces para entender cómo pasó. Si el log plano te da solo duración total, todavía no tenés una explicación. Tenés una pista.

Este artículo fue publicado originalmente en juanchi.dev

The Hidden Reason AI Botches Your Specs (and the Layered Fix That Works)

I adopted Spec-Driven Development expecting the AI to stop making mistakes. It wasn’t that simple.

Polished specs, configured skills, a smooth process. Even so, every time I asked the AI to “build a screen,” the result came back muddled: business rules ignored, inconsistencies everywhere.

What bothered me is that I couldn’t blame the tool or the spec. The method was right. It was just operating at the wrong granularity.

Because “build a screen” isn’t a single task. Even when I broke it into multiple tasks, with planning and execution, running the full SDD cycle, it still carried three coupled decisions:

  • how the interface should look,
  • which rules the client’s domain allows,
  • and how the API should expose the data.

Three distinct decisions, scattered across tasks but never truly separated. And that’s the part that took me a while to see: SDD organized my work, but it didn’t decouple my decisions. The model behaved the way it always had, resolving all three at once, with full confidence. It looked correct until the first review, when the inconsistencies, the ignored business rules, and the rework showed up.

The turning point

Those three decisions aren’t one. They’re independent layers, and they should be decoupled. The presentation layer is one. The domain model is another. The backend is another. You can’t resolve all three in the same step.

I think of it like assembling furniture: nobody sands, assembles, and paints at the same time. Each stage has a single objective, and that’s what makes it executable.

Out of this came a way of working that has worked remarkably well for me: layered SDD. Each layer is its own spec, with a single objective, that deliberately ignores everything outside its scope.

One important clarification before the workflow: modeling the domain is not building the backend. Domain modeling means describing the client’s entities, business rules, and relationships. It’s an exercise in understanding, independent of API, persistence, or infrastructure. The backend comes much later.

The workflow I use today, layer by layer

  1. I design the interface. This is the most counterintuitive step, and also the most powerful one. I build a UI flow with sample data, meaning hardcoded placeholder values living in the components themselves, just enough for the interface to render and the UX flow to be visible. No contract, no data layer, nothing structured behind it. This is deliberate: at this step there is no data model yet. I design the best possible interface, as if the current system didn’t exist. And yes, I do this knowing I’ll refactor later. That’s fine. Here I want to capture the ceiling of the UX, not immediate feasibility. And it doesn’t have to be the complete frontend in the first prompt: an initial UI is enough to start.

  2. I model the data. I do the opposite: I set the interface aside. I take the data the UI consumes and model it on its own, the entities, business rules, and relationships, fully decoupled from the presentation, without ever touching the UI to do it. I don’t think about which field appears where, only about the consistency of the data model itself.

  3. I build the comparison plan. With both layers ready, I put one against the other and map the gaps in both directions: where the UI requires something the data model doesn’t support, and where the model exposes something the UI doesn’t consume. This is where the real trade-offs become explicit, named, and documented, instead of ambushing me during implementation.

  4. I refactor the UI against the real data model. I rewrite the interface based on what the data model actually supports. In the traditional workflow, this adjustment becomes that end-of-project “cleanup.” Here it’s a planned step, a design decision, not rework.

  5. I build the mock layer. Now I implement mocks, and here the term is precise: mocks that reproduce the contract and behavior of the real API. Note that this is only possible after step 4, because a mock presupposes that a contract exists to be imitated, and that contract only came into being after the modeling and the comparison. This layer lets me validate complete flows without depending on a backend, iterating, including iterating with AI, in short cycles.

  6. I build the real backend. Only now does the definitive implementation come in, backed by an already validated data model and an already stabilized interface. No discovering requirements halfway through.

  7. I do the E2E integration. I wire the layers together and validate the flow end to end.

The whole process is iterative: an initial UI, an initial data model, a UI v2, a data model v2, and so on. Each pass through the layers is tighter than the last.

Why this works so well with AI

In my experience, the reason is simple: AI models perform much better with small, well-defined problems. When I coupled UX, business rules, and architecture into the same scope, too much ambiguity was left over, and the model filled that ambiguity with assumptions. Decomposing into layers cut that noise drastically: each spec became narrow enough for the model to get it right.

This may sound counterintuitive. The common wisdom is “the more specific context, the better,” so why not feed the model everything at once? Because that level of specificity can only come after you have something concrete in hand.

When you’re building a feature, you need to see the bottlenecks, and bottlenecks don’t reveal themselves up front. They surface when you approach the feature from different angles, build out different flows, and confront them against each other. The layers are exactly that confrontation: each one produces something real, and putting them side by side is what exposes the constraints you would otherwise discover too late.

It seems slower, but it isn’t. The method doesn’t add new steps, it just moves decisions earlier, decisions that used to come back as rework at the end of the cycle. The “cleanup” doesn’t disappear, it becomes part of the plan.

I’ve been using this approach even with teams at S&P 500 company, and the result has been consistent: more predictability, less chaotic refactoring, and less friction than with traditional Spec-Driven Development.

I don’t think it’s a silver bullet. But for me, it changed the way I work with AI in software development for good.

How about you? How are you structuring AI-assisted development today?

Merge Sort vs Bubble Sort — Why 800 Comparisons Beats 147 Every Time

Most developers know Merge Sort is faster
than Bubble Sort. But watching it happen
makes the difference visceral.

The Numbers

Bubble Sort: 800+ comparisons
Merge Sort: 147 comparisons
Same 30 elements. Same result.

Why Such a Difference?

Bubble Sort compares adjacent elements
and moves them one step at a time —
O(n²) in worst case.

Merge Sort divides the array completely
down to single elements, then merges
them back in sorted order — O(n log n)
guaranteed every single time.

The Key Code

The entire algorithm is built on one
recursive insight:

javascript
function mergeSort(arr, left, right) {
if (left >= right) return;
const mid = Math.floor((left + right) / 2);
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
merge(arr, left, mid, right);
}

A single element is already sorted by
definition. Everything else is just
merging sorted groups together.

Built in React — Zero Libraries

Animation state managed entirely with
useState and useRef. No external
animation libraries.

Color coding:

  • Purple = unsorted
  • Gold = currently merging
  • Red = comparing
  • Cyan = fully sorted

Watch It

What’s Next

Quick Sort — the algorithm that actually
powers sorting in most programming
languages. Spoiler: it’s faster than
Merge Sort in practice despite worse
worst-case complexity.

Subscribe to AlgoCanvas:
👉 youtube.com/@AlgoCanvas

İbn Haldun Akademi'nin kapanış töreni yapıldı

İbn Haldun Üniversitesi tarafından düzenlenen 4. İbn Haldun Akademi’nin kapanış töreni yapıldı. Rektör Prof. Dr. Atilla Arkan, yapay zeka ve dijitalleşmenin yükseköğretimi dönüştürdüğünü vurguladı. Törende ödüller sahiplerini buldu.

FindBug – Self-hosted Error Tracking & Performance Monitoring for Rails (v0.5.0)

I got tired of paying $26/seat/month for error tracking on side projects, so I built FindBug – a Rails engine that captures exceptions and performance data with all the data living on your own infrastructure. Zero SaaS, full ownership, MIT-licensed.

🌐 Website: findbug.dev
📗 Documentation: findbug.dev/docs
💎 RubyGems: rubygems.org/gems/findbug
⭐ GitHub: github.com/ITSSOUMIT/findbug

The pain points that pushed me to build this

If you’ve run a Rails monolith with Sentry / Bugsnag / Rollbar, you’ve felt these:

  • Cost scales with team size, not value delivered. A 5-person team is $130+/mo before catching a single bug.
  • Data lives on someone else’s servers – fine for most apps, awkward for anything with PII, GDPR, or SOC concerns.
  • Network dependency – if their edge is having a bad day, you lose telemetry exactly when you need it most.
  • Setup ritual – API keys, env-specific DSNs, separate dashboards to log into, SDK version drift.

What FindBug looks like

# Gemfile
gem "findbug", "~> 0.5.0"
$ bundle install
$ rails generate findbug:install
$ rails db:migrate

That’s the entire install. After this:

  • Unhandled exceptions in controllers and middleware are captured automatically
  • HTTP performance + SQL queries + N+1 patterns are instrumented
  • A dashboard is mounted at /findbug
  • A background thread flushes data from Redis to your database every 30 seconds – no Sidekiq required

Architecture: never block the request

The non-negotiable design rule is that capturing an error must not slow down your users:

Request  →  Middleware catches exception  →  Scrub PII
         →  Push to Redis buffer  (Thread.new, ~1–2ms)
         →  Background persister flushes Redis → DB every 30s in batches of 100
         →  Dashboard reads from DB on demand
  • Capture overhead: ~1–2ms – single LPUSH on a dedicated Redis connection pool, fired via Thread.new
  • HTTP request blocking: 0ms – capture is fire-and-forget
  • Circuit breaker: 5 consecutive Redis failures opens the breaker for 30s, so your app stays fast even if Redis is unreachable
  • Dedicated connection pool: separate from your app’s Redis/Sidekiq, so a spike in error volume can’t starve your cache

Manual capture

When you want to swallow an exception but still track it:

begin
  risky_operation
rescue => e
  Findbug.capture_exception(e, user_id: current_user.id)
end

Or capture a non-exception event:

Findbug.capture_message("Rate limit exceeded", :warning, user_id: 123)

Track a custom block for performance:

Findbug.track_performance("external_api_call") do
  ExternalAPI.fetch_data
end

Controller helpers

Attach request-scoped context that’s included with every error captured during that request:

class ApplicationController < ActionController::Base
  before_action :set_findbug_context

  def set_findbug_context
    findbug_set_user(current_user)
    findbug_set_context(
      plan: current_user&.plan,
      organization_id: current_org&.id
    )
  end
end

Breadcrumbs work the way you’d expect:

findbug_breadcrumb("User clicked checkout", category: "ui")
findbug_breadcrumb("Payment API called", category: "http", data: { amount: 99.99 })

Database-agnostic (v0.5.0)

This was the biggest improvement in the latest release. The migrations and model layer detect your ActiveRecord::Base.connection.adapter_name at runtime and pick the right column types and SQL functions:

Adapter JSON column Time bucketing SQL
PostgreSQL jsonb date_trunc(...)
MySQL json DATE_FORMAT(...) / DATE(...)
SQLite text (with JSON serialisation) strftime(...) / DATE(...)

Application code is identical regardless of adapter – the JSON accessors always return native Ruby Hash / Array.

If you’re writing your own migrations against the same multi-DB strategy, the helper is public API:

Findbug::AdapterHelper.json_column_type      # :jsonb / :json / :text
Findbug::AdapterHelper.json_default({})
Findbug::AdapterHelper.date_trunc_sql("hour", "captured_at")

Alerts configured at runtime, not in code

Email / Slack / Discord / Webhook channels live in a DB table managed from the dashboard at /findbug/alerts. No redeploy to add a webhook. Same-error notifications are throttled by fingerprint (default 5-minute window).

Compared to existing solutions

Sentry / Bugsnag FindBug
Cost $26+ per seat / month Free, MIT
Data location Third-party servers Your infra
Network dependency Required None
Setup API keys + SDK + per-env config One gem, one command
Alert config Their dashboard Your dashboard
Customisation Bounded by their UI Fork the gem
Job scheduler needed No No (built-in thread)

Stack

  • Ruby 3.1+ · Rails 7.0+ (7.x and 8.x both tested) · Redis 4.0+
  • PostgreSQL / MySQL / SQLite – all supported, adapter auto-detected
  • 79 RSpec examples covering adapter detection, JSON-column normalisation, model behaviour, and aggregation

What it doesn’t do (yet)

Being honest about the gap:

  • No source-map handling – server-side errors only for now
  • No frontend JS error tracking – on the v0.6 roadmap for Rails apps that render views
  • No multi-project support – one gem = one app
  • No SaaS option – you self-host or you don’t use it

Links

If you find this useful, a ⭐ on GitHub means a lot – it helps other Rails developers find the gem:

GitHub logo

ITSSOUMIT
/
findbug

Self-hosted error tracking and performance monitoring for Rails. Sentry-like functionality with all data on your infrastructure.

Built by Soumit Das. Would love feedback – especially from anyone running it on MySQL (the new adapter-agnostic path is fresh) or anyone with thoughts on the frontend-error-tracking direction.

How I Built Memory Concierge: An AI Hotel Concierge That Remembers Guests

Memory Concierge dashboard preview

How I Built Memory Concierge: An AI Hotel Concierge That Remembers Guests

I built Memory Concierge, an AI-powered hotel concierge prototype that remembers returning guests and helps hotel staff personalize a stay before the guest even asks.

The project is built around a fictional luxury hotel called The Aurelian Vale.

The idea is simple:

What if hotel service could feel proactive instead of repetitive?

Instead of making guests repeat their preferences every time they return, Memory Concierge helps staff remember, prepare, and adapt the guest experience in real time.

Live Demo | GitHub Repo

Memory Concierge dashboard preview

Memory Concierge dashboard for The Aurelian Vale.

Table of Contents

  • Why I Built This
  • What Memory Concierge Does
  • Tech Stack
  • How I Used AI
  • How The App Works
  • Using NVIDIA NIM
  • Why Structured JSON Matters
  • Real-Time Adaptation
  • Adding Voice With ElevenLabs
  • What I Learned
  • What I Would Build Next

Why I Built This

I wanted to build an AI project that felt like a real workflow, not just another chatbot.

Hotels already collect a lot of useful guest information:

  • Room temperature preferences
  • Pillow type
  • Dietary restrictions
  • Favorite drinks
  • Morning routines
  • Meeting schedules
  • Past celebrations
  • Arrival details

But that information is often scattered or underused.

A returning guest may still need to repeat the same preferences every time they arrive.

I wanted to explore a different experience:

The hotel already remembers you, and the concierge quietly adapts before you ask.

That became the core idea behind Memory Concierge.

What Memory Concierge Does

Memory Concierge is a staff-facing dashboard.

A staff member can select an arriving guest, and the app generates:

  • A personalized welcome message
  • Room preparation details
  • Dinner or arrival adjustments
  • A tomorrow morning brief
  • A memory-based concierge note
  • Real-time adaptation for events like delayed flights
  • Optional voice playback for the welcome message

The main demo guest is Sarah Chen, a returning guest whose flight is delayed by two hours.

The concierge already knows Sarah prefers:

  • A high-floor city-view room
  • 68°F room temperature
  • Firm pillows
  • Burgundy Pinot Noir
  • Sparkling water
  • A yoga mat
  • A morning gym routine

When her delayed flight is selected, the app generates an updated stay plan and a calm guest-facing message.

That is the main product idea: proactive hospitality powered by memory.

Tech Stack

I built Memory Concierge with:

  • Next.js 14 App Router
  • TypeScript
  • Tailwind CSS
  • Framer Motion
  • Lucide React
  • NVIDIA NIM
  • Llama 3.1 70B Instruct
  • ElevenLabs text-to-speech
  • Local JSON guest data
  • Codex as a development partner

The project is intentionally lightweight. There is no database and no authentication in the prototype. Guest memory is stored locally in data/guests.json so the demo stays simple and reliable.

How I Used AI

AI was used in two different ways.

First, I used Codex while building the project. It helped me scaffold the app, debug TypeScript issues, improve the README, polish the UI, and document the project.

Second, the app itself uses AI at runtime. When a guest is selected, the app sends guest context to NVIDIA’s Llama model and asks it to return structured JSON for the dashboard.

So the AI is not just chatting — it is powering a workflow.

How The App Works

The project structure looks like this:

app/
  api/
    adapt/route.ts
    voice/route.ts
    welcome/route.ts
  globals.css
  layout.tsx
  page.tsx

components/
  AlertBanner.tsx
  DashboardHeader.tsx
  GuestSelector.tsx
  ItineraryCard.tsx
  MemoryTimeline.tsx
  RoomReadyCard.tsx
  TriggerPanel.tsx
  VoiceButton.tsx

data/
  guests.json

lib/
  elevenlabs.ts
  json.ts
  openai.ts

The main flow is:

  1. A staff member selects a guest.
  2. The frontend sends that guest profile to /api/welcome.
  3. The API route calls the AI model.
  4. The model returns structured JSON.
  5. The dashboard renders the welcome, room status, dinner note, and tomorrow brief.
  6. A staff member can trigger a real-time event.
  7. The app calls /api/adapt.
  8. The model returns updated actions and a guest-facing message.

Using NVIDIA NIM

I used the OpenAI JavaScript SDK, but pointed it at NVIDIA’s OpenAI-compatible endpoint.

import OpenAI from "openai";

export const NVIDIA_MODEL = "meta/llama-3.1-70b-instruct";

export const openai = new OpenAI({
  apiKey: process.env.NVIDIA_API_KEY ?? "missing-nvidia-api-key",
  baseURL: "https://integrate.api.nvidia.com/v1"
});

The model used is:

meta/llama-3.1-70b-instruct

I chose this because the project needs clean JSON responses that the frontend can render reliably.

Why Structured JSON Matters

One important design decision was asking the model for structured output instead of free-form text.

The /api/welcome route asks for JSON like this:

{
  "welcome_message": "A warm personalized welcome",
  "room_status": "Room preparation details",
  "dinner_note": "Dinner or arrival adjustment",
  "tomorrow_brief": "Morning schedule preview",
  "concierge_note": "Personal memory-based note"
}

This matters because each field maps to a specific part of the dashboard. The model is not just generating text — it is generating UI-ready data. That made the app feel much more reliable.

Real-Time Adaptation

The /api/adapt route powers the event buttons in the dashboard.

Example events include:

  • Flight delayed 2 hours
  • Guest went vegan
  • Needs quiet room
  • Dinner moved to 9 PM

The model returns:

{
  "alert_title": "Flight Delay Handled",
  "alert_detail": "The arrival plan has been adjusted around the delayed flight.",
  "actions_taken": [
    "Updated check-in timing",
    "Held room preparation",
    "Adjusted dinner reservation"
  ],
  "guest_message": "Your arrival has been adjusted and everything will be ready when you arrive."
}

This is what makes the project feel like an active concierge system instead of a static guest profile page.

Adding Voice With ElevenLabs

I also added optional voice playback.

When the user clicks Play Welcome, the app sends the generated welcome message to /api/voice. That route uses ElevenLabs to generate MP3 audio.

const audioStream = await elevenlabs.textToSpeech.convert(CONCIERGE_VOICE_ID, {
  text,
  model_id: "eleven_v3",
  output_format: "mp3_44100_128",
  voice_settings: {
    stability: 0.6,
    similarity_boost: 0.85,
    style: 0.2,
    use_speaker_boost: true
  }
});

I made voice generation user-triggered because it costs API credits and should only run when the user actually wants to hear the welcome message.

What I Learned

The biggest lesson was that AI products work better when the model has a clear job.

For this project, the model was not asked to be a generic assistant. It was asked to act as a luxury hotel concierge and return specific structured fields. That made the output much easier to trust and display.

I also learned that polish matters a lot in AI demos. Small details like loading states, clear cards, strong README documentation, a screenshot, and a simple story make the project easier to understand.

What I Would Build Next

  • A demo GIF showing the full interaction
  • More guest profiles
  • Database-backed guest memory
  • Authentication and staff roles
  • Real flight-status integration
  • Staff approval before sending messages
  • Audit logs for AI-generated actions
  • Multilingual concierge responses
  • Schema validation for AI responses
  • Tests for API routes and UI states

Final Thoughts

Memory Concierge helped me think about AI as a workflow layer.

The most useful AI products may not be the ones that chat the most. They may be the ones that quietly read context, return structured output, and help people act faster.

That was the goal of Memory Concierge:

Make guest memory useful at the exact moment service happens.

Try the live demo | View the source code