<?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: evanshepherd5623</title>
    <description>The latest articles on DEV Community by evanshepherd5623 (@evanshepherd5623).</description>
    <link>https://dev.to/evanshepherd5623</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%2F4063781%2F50c6889c-4b34-4d25-aedb-be28021042df.png</url>
      <title>DEV Community: evanshepherd5623</title>
      <link>https://dev.to/evanshepherd5623</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/evanshepherd5623"/>
    <language>en</language>
    <item>
      <title>Picking an LLM API gateway for a private knowledge base: cost per token, caching, batch</title>
      <dc:creator>evanshepherd5623</dc:creator>
      <pubDate>Tue, 11 Aug 2026 16:36:31 +0000</pubDate>
      <link>https://dev.to/evanshepherd5623/picking-an-llm-api-gateway-for-a-private-knowledge-base-cost-per-token-caching-batch-1ch8</link>
      <guid>https://dev.to/evanshepherd5623/picking-an-llm-api-gateway-for-a-private-knowledge-base-cost-per-token-caching-batch-1ch8</guid>
      <description>&lt;p&gt;Thirty seconds is about how long a nurse will wait for an answer from an internal-policy assistant before giving up and opening the PDF. That deadline, not the price list, is what decides this comparison. Pick a gateway on two numbers at once — what one answered question costs in tokens, and how much latency the extra hop adds — and keep caching and batch as separate budgets rather than folding them into a single "cost per token" figure.&lt;/p&gt;

&lt;p&gt;Two clocks. Not one.&lt;/p&gt;

&lt;p&gt;If you already run infrastructure and want routing rules under your own version control, self-host LiteLLM and be done. If you want a hosted catalogue behind one credential and no client library to track, a hosted compatible gateway is the shorter path, and Infrai is a good option for a Node.js team here because it's a plain REST API over HTTPS with no SDK to install — the gateway becomes one more HTTP call from the service you already run. And if exactly one vendor's model will ever touch your data, call OpenAI, Anthropic or Google directly and skip the middle layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two clocks are running and they pull against each other
&lt;/h2&gt;

&lt;p&gt;Before: your app holds a vendor SDK, one API key, one billing account. Latency is model latency plus your own network. The mental model is a straight line — app, SDK, vendor, back.&lt;/p&gt;

&lt;p&gt;After: your app holds one client pointed at a gateway, and the gateway holds the vendor keys. The line grows a node in the middle. That node is where model swapping, retries, caching signals and per-call cost accounting live, and it's also where you pay somewhere between a few and a few dozen milliseconds of routing overhead, depending on whether the gateway sits in the same region as your service and the same region as the upstream vendor. For a private knowledge base in healthtech the pipeline usually has three stages anyway: retrieve chunks from the vector store, filter or rerank them, then write the answer. Each stage can run on a different tier of model, and the whole reason to put a gateway in the middle is that changing which tier handles which stage should be a config edit rather than a deploy of three different SDK integrations.&lt;/p&gt;

&lt;p&gt;The quality-versus-latency axis lives entirely in stage three. A small model answers a policy question in a second and gets the nuance wrong maybe one time in twenty. A frontier model takes four seconds and doesn't. In a clinical setting, the wrong-one-in-twenty is the number your compliance reviewer will ask about, so the cheap model belongs in stages one and two, where its job is filtering, not answering.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should you compare cost per token across OpenAI, Claude, and Gemini endpoints?
&lt;/h2&gt;

&lt;p&gt;Not by lining up published per-token rates. Those rates describe the meter, not the bill.&lt;/p&gt;

&lt;p&gt;The number worth comparing is cost per answered question, which means measuring the shape of your own traffic first: how many tokens of retrieved context you stuff into each prompt, how long the answers run, and how much of that prompt is identical on every single call. In a private-KB assistant the last one dominates. The system preamble, the formatting rules, the safety instructions and often the top-ranked policy excerpts repeat across thousands of questions, and every major vendor now discounts those repeated input tokens through prompt caching. If your gateway forwards the cache signals and reports back whether a call hit the cache, you can actually verify the discount instead of assuming it.&lt;/p&gt;

&lt;p&gt;Batch is the other budget. Nightly re-tagging of newly ingested documents, backfilling summaries, generating embeddings for a new corpus — nobody is watching a spinner for any of that, so it belongs on an asynchronous batch flow rather than the same synchronous endpoint your clinicians hit. Gateways differ sharply here: some expose a batch submit-and-poll flow behind the same key as chat, some pass you through to the vendor's own batch API, and some don't offer batch at all and quietly turn your nightly job into ten thousand synchronous calls.&lt;/p&gt;

&lt;p&gt;So the comparison table I'd build has three columns of numbers, none of which come from a pricing page: tokens per answered question, cache hit rate on the repeated prefix, and the share of your monthly volume that could legally run overnight instead of in real time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill nobody budgets: keys, SDKs, and time to first useful answer
&lt;/h2&gt;

&lt;p&gt;Here's where the integration friction shows up. Supporting three model families directly means three vendor accounts, three keys in your secret store, three billing portals to reconcile at month end, and three SDKs whose retry semantics, streaming shapes and error classes have nothing in common. Streaming is the sharpest example: they all speak Server-Sent Events, but the event names and the terminal sentinel differ enough that your Node.js handler ends up with a branch per vendor.&lt;/p&gt;

&lt;p&gt;An OpenAI-compatible surface collapses most of that into a &lt;code&gt;baseURL&lt;/code&gt; swap, because your existing client keeps working and the vendor choice moves into the &lt;code&gt;model&lt;/code&gt; field.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;How you integrate&lt;/th&gt;
&lt;th&gt;Time to first useful call&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;th&gt;Main limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct vendor SDKs (OpenAI, Anthropic, Google)&lt;/td&gt;
&lt;td&gt;One SDK per vendor&lt;/td&gt;
&lt;td&gt;Minutes per vendor, then N-way glue&lt;/td&gt;
&lt;td&gt;You are committed to one vendor&lt;/td&gt;
&lt;td&gt;Key and SDK sprawl grows with each model family&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted LiteLLM&lt;/td&gt;
&lt;td&gt;Deploy and operate a proxy&lt;/td&gt;
&lt;td&gt;Hours to a day&lt;/td&gt;
&lt;td&gt;You want routing rules in your own repo&lt;/td&gt;
&lt;td&gt;You now run and page for another service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenRouter&lt;/td&gt;
&lt;td&gt;Compatible HTTP surface, hosted&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;td&gt;Broad model catalogue, quick experiments&lt;/td&gt;
&lt;td&gt;Another party in the data path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai&lt;/td&gt;
&lt;td&gt;Plain REST over HTTPS, OpenAI-compatible surface&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;td&gt;Node.js teams who want one credential across backend services&lt;/td&gt;
&lt;td&gt;Another party in the data path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama or vLLM on your own hardware&lt;/td&gt;
&lt;td&gt;Self-managed inference&lt;/td&gt;
&lt;td&gt;Days&lt;/td&gt;
&lt;td&gt;Data must never leave your network&lt;/td&gt;
&lt;td&gt;You own capacity planning and model upgrades&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second thing Infrai buys you beyond the missing SDK is narrower than the marketing line suggests, and it's the part that matters for a small platform team: one key and one bill covers the vector store and the scheduled jobs sitting next to the model calls, so the credential your nightly tagging worker already carries is the same one the chat path uses. Billing runs on usage with no monthly minimum, which mostly means a pilot answering forty questions a day is cheap enough to leave running while you argue about architecture. The discovery surface is public and needs no key, so you can read the request and response schema for any capability before you sign up for anything — I like that more than I expected to, because it makes the "will this fit" question answerable in a browser tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal Node.js example
&lt;/h2&gt;

&lt;p&gt;The chat path is the OpenAI SDK with two lines changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// One credential. The vendor keys live on the gateway side, not in your secret store.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&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;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// ifr_...&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.infrai.cc/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxRetries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                        &lt;span class="c1"&gt;// backs off on 429 and honours Retry-After&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PREAMBLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Answer only from the retrieved policy excerpts. If they do not cover the question, say so.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;answerFromKb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;excerpts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deepseek-chat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PREAMBLE&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;excerpts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;---&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n\nQuestion: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// The OpenAI-compatible response carries a top-level infrai object:&lt;/span&gt;
    &lt;span class="c1"&gt;// { cost_usd, vendor, model, region, cache, request_id }&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;infrai&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;infrai&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cost_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vendor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A 4xx body carries the reason. Surface it instead of retrying blindly.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;meta&lt;/code&gt; object is the whole reason to route through something instead of calling a vendor directly. You get the per-call cost and the cache flag on the same response you were already parsing, which turns "are we saving money on cached prefixes" from a spreadsheet exercise into a log line you can aggregate.&lt;/p&gt;

&lt;p&gt;Before you pin a model, read the catalogue rather than a blog post — it's an unauthenticated GET and it carries the current prices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.infrai.cc/v1/ai/models?capability=chat&amp;amp;available=true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`model list &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_input_per_mtok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_output_per_mtok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modalities&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire those two together and your model choice per pipeline stage becomes a string in a config file. That's the payoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a specialist still wins, and two objections worth taking seriously
&lt;/h2&gt;

&lt;p&gt;The catch is that a gateway is a party in the data path, and in healthtech that's a legal question before it's an engineering one. If your compliance review requires a signed agreement with the model vendor itself, or residency you can point at on a map for both the US and EU halves of your traffic, go direct or self-host. Same answer if you need provisioned throughput: dedicated capacity is an enterprise contract with the vendor, not something a shared gateway can hand you. And if content classification is central to your product rather than incidental, note that Infrai doesn't offer a dedicated moderation endpoint — you'd run classification through a chat model with a JSON schema, which works but is not what a purpose-built moderation service gives you.&lt;/p&gt;

&lt;p&gt;The first objection I hear is that a gateway is another thing to be down. Fair, and the honest answer is that you've traded three vendor dependencies for one gateway dependency plus its upstreams, which is better for correlated failure and worse for blast radius. Keep a direct-to-vendor fallback path in your config for the stage that actually faces users. It costs you one extra client and it means the routing layer is never a single point of failure for the clinician waiting thirty seconds.&lt;/p&gt;

&lt;p&gt;The second is lock-in, and I think it's mostly backwards. A compatible surface is the least sticky integration available to you, because the exit is a &lt;code&gt;baseURL&lt;/code&gt; edit. What genuinely locks you in is the stuff around the model calls — the vector collections, the scheduled jobs, the stored artefacts. Migrate those deliberately, or keep them somewhere you're happy to stay.&lt;/p&gt;

&lt;p&gt;If the boundary in this article matches your system, the gateway comparison notes at &lt;a href="https://docs.infrai.cc/en/guides/ai/answers/cheapest-openai-claude-gemini-compatible-api-gateway-20/" rel="noopener noreferrer"&gt;docs.infrai.cc&lt;/a&gt; go through what a compatible surface does and doesn't cover. Then measure your own cost per answered question before you commit to anything, because your traffic shape will disagree with mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;MDN, Using Server-Sent Events — &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;LiteLLM, self-hosted LLM gateway (open source) — &lt;a href="https://github.com/BerriAI/litellm" rel="noopener noreferrer"&gt;https://github.com/BerriAI/litellm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenAI, Batch API guide — &lt;a href="https://platform.openai.com/docs/guides/batch" rel="noopener noreferrer"&gt;https://platform.openai.com/docs/guides/batch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Anthropic, Message Batches — &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/batch-processing" rel="noopener noreferrer"&gt;https://docs.anthropic.com/en/docs/build-with-claude/batch-processing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google, Gemini API documentation — &lt;a href="https://ai.google.dev/gemini-api/docs" rel="noopener noreferrer"&gt;https://ai.google.dev/gemini-api/docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>api</category>
      <category>node</category>
      <category>healthtech</category>
    </item>
    <item>
      <title>Node App Telemetry: Weighing Pino, Logtail, Datadog, and Hosted APIs</title>
      <dc:creator>evanshepherd5623</dc:creator>
      <pubDate>Wed, 05 Aug 2026 18:25:17 +0000</pubDate>
      <link>https://dev.to/evanshepherd5623/node-app-telemetry-weighing-pino-logtail-datadog-and-hosted-apis-281</link>
      <guid>https://dev.to/evanshepherd5623/node-app-telemetry-weighing-pino-logtail-datadog-and-hosted-apis-281</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer: for simple production logging in a Node Express app, keep Pino at the application edge, send structured events to a central destination, and choose that destination by the operational questions you must answer after deployment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a junior developer running an Express service, Pino plus a lightweight hosted log API is a practical starting point when the job is ingestion and search. Pino plus Logtail belongs on the shortlist for that same focused job. Pino plus Datadog deserves a closer look when logs must sit inside a wider observability workflow. The decision changes if you need alert delivery, distributed trace exploration, compliance controls, or synthetic checks.&lt;/p&gt;

&lt;p&gt;My test is deliberately boring: can I follow one failed request from the Express handler to the central search result without teaching the app a vendor-specific logging model? Start there. Fancy dashboards can wait.&lt;/p&gt;

&lt;p&gt;That's enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should simple production logging for a Node Express app actually preserve?
&lt;/h2&gt;

&lt;p&gt;The useful unit isn't a line of prose. It's an event with enough context to reconstruct what happened. I teach teams to begin with four fields: &lt;code&gt;request_id&lt;/code&gt;, &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;trace_id&lt;/code&gt;, and &lt;code&gt;environment&lt;/code&gt;. Keep the message short, keep the level explicit, and let the fields carry the debugging context. If a user reports a failed checkout, &lt;code&gt;request_id&lt;/code&gt; narrows one execution, &lt;code&gt;user_id&lt;/code&gt; connects nearby activity, &lt;code&gt;trace_id&lt;/code&gt; can correlate cooperating services, and &lt;code&gt;environment&lt;/code&gt; prevents a staging event from masquerading as production.&lt;/p&gt;

&lt;p&gt;Before: an engineer searches for “checkout failed,” gets 600 similar strings, and guesses.&lt;/p&gt;

&lt;p&gt;After: the engineer starts with a reported request ID, confirms the environment, and follows the same trace ID through related structured events. That's the whole diagram in words: &lt;strong&gt;Express request -&amp;gt; Pino JSON -&amp;gt; central ingestion -&amp;gt; field search -&amp;gt; one explainable failure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This does not turn logs into traces. Shared &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt; fields provide correlation, but they don't create a span tree or a distributed tracing query experience. That distinction matters. A log destination can help me gather the breadcrumbs while still being the wrong tool for service maps, parent-child timing, or critical-path analysis.&lt;/p&gt;

&lt;p&gt;I also separate visibility from notification. Central search answers “what happened?” after somebody asks. Alert routes, threshold rules, phone calls, SMS, and webhook delivery answer “who gets interrupted?” A hosted log API without those routes needs a polling query and an alerting component around it. Likewise, it won't tell you that a scheduled task silently failed to run; a heartbeat monitor such as Healthchecks covers that shape of failure better.&lt;/p&gt;

&lt;h2&gt;
  
  
  A copyable Pino baseline before choosing the backend
&lt;/h2&gt;

&lt;p&gt;Here is the smallest Express setup I would ship as a baseline. It emits structured request completion events, carries incoming correlation IDs when present, creates a request ID when absent, and never mixes the production destination decision into route code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pino&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pino&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pino&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;traceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-trace-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anonymous&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;finish&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;traceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;development&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/health&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;server listening&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with the normal Pino and Express packages plus their TypeScript types. The destination can consume the resulting JSON without forcing every handler to know where logs live. That boundary is valuable: changing the transport should be an operations change, not a rewrite of business logic.&lt;/p&gt;

&lt;p&gt;Keep one caveat in view. Redaction is an application responsibility in this baseline. Don't place tokens, passwords, or raw personal data into &lt;code&gt;user_id&lt;/code&gt; or messages. A clean schema is easier to search, but it also makes accidental sensitive fields consistently discoverable. I prefer an allowlist of logged fields at the request boundary because it is short enough for a reviewer to verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare the destinations by the failure you need to investigate
&lt;/h2&gt;

&lt;p&gt;The product names matter less than the investigation they support. I use this table as a first-pass decision aid, then verify the selected product's current documentation during a spike. Your mileage may vary because retention policy, team ownership, and existing contracts can outweigh a neat technical fit.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Best initial fit&lt;/th&gt;
&lt;th&gt;Main reason to choose it&lt;/th&gt;
&lt;th&gt;Reason to choose something else&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pino + Logtail&lt;/td&gt;
&lt;td&gt;Focused centralized logging&lt;/td&gt;
&lt;td&gt;Keep the evaluation centered on ingestion and log search&lt;/td&gt;
&lt;td&gt;Choose Datadog when the decision must include a broader observability workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pino + Datadog&lt;/td&gt;
&lt;td&gt;Teams evaluating logs alongside a wider operations stack&lt;/td&gt;
&lt;td&gt;One candidate when logging is only part of the operational requirement&lt;/td&gt;
&lt;td&gt;Choose a focused destination when a larger platform adds process you won't use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pino + a conventional hosted log API&lt;/td&gt;
&lt;td&gt;Small service with a stable logging contract&lt;/td&gt;
&lt;td&gt;A narrow HTTP boundary can keep application logging portable&lt;/td&gt;
&lt;td&gt;Avoid a thin API when you require native alert delivery, tracing, replay, or compliance workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pino + Infrai&lt;/td&gt;
&lt;td&gt;App debugging centered on ingestion and search&lt;/td&gt;
&lt;td&gt;One REST API keeps the application contract stable while the vendor behind a capability can change; the wider platform uses one key and one bill&lt;/td&gt;
&lt;td&gt;Not suitable when logs need user-level deletion, bulk export, subscriptions, configurable retention, native alerts, or distributed trace queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That final row needs care. Search filters for &lt;code&gt;logs.search&lt;/code&gt; are not declared in discovery parameters, so I would validate the supported query patterns during integration rather than promise field syntax in an article. The correct public routes are &lt;code&gt;POST /v1/logs/ingest&lt;/code&gt; and &lt;code&gt;GET /v1/logs/search&lt;/code&gt;, but request fields and search filters should come from discovery, not from REST conventions or guesses.&lt;/p&gt;

&lt;p&gt;The catch is scope. For compliance-heavy logging, a missing per-user deletion interface and missing bulk export or subscription interface are decisive boundaries. For crash diagnostics, lack of source-map decoding, Electron minidump symbolication, and Session Replay points elsewhere. A lightweight destination can still be the right answer; it just isn't a compressed replacement for every observability discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can a retry hide the real production logging failure?
&lt;/h2&gt;

&lt;p&gt;Yes. I've watched it happen.&lt;/p&gt;

&lt;p&gt;I hit a &lt;code&gt;429&lt;/code&gt; during a burst on one service, and our retry loop quietly swallowed it. The dashboard looked calm because the client kept retrying; the missing evidence only became obvious when &lt;code&gt;17&lt;/code&gt; request IDs from a support batch produced no searchable event. I had taught the team to correlate logs, yet the transport had erased the very trail we needed. The painful part wasn't rate limiting itself — that is normal backpressure — but treating a retry as proof of delivery.&lt;/p&gt;

&lt;p&gt;The fix in my mental model was crisp. A transport must recognize &lt;code&gt;429&lt;/code&gt;, honor &lt;code&gt;Retry-After&lt;/code&gt; when it is present, apply exponential backoff, and surface exhaustion as an operational signal. It must also inspect every response status and retain the real 4xx response body because that body carries the reason. Tight loops are out. Silent loops are worse.&lt;/p&gt;

&lt;p&gt;For writes, retry semantics deserve the same attention. If a destination supports an idempotency key, use a stable client-supplied value so a repeated attempt cannot double-apply the event. If its ingestion contract doesn't specify idempotency, I'm not sure why anyone would assume retries are harmless; I would test duplicate behavior explicitly and make downstream queries tolerant of a stable event identifier only where the documented schema supports one.&lt;/p&gt;

&lt;p&gt;This is also why I avoid showing a hand-invented ingestion payload. The sample above establishes the application event. The destination adapter must be written against its current request schema, including its authentication and retry contract. Copy-pasteable code is useful only when every field is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should the architecture stop growing?
&lt;/h2&gt;

&lt;p&gt;Stop when the system answers the incident questions your team actually owns. For one Express service, structured logs plus central ingestion and search may be enough. Add an alerting path when someone must react without polling. Add Healthchecks or another heartbeat monitor when silence itself is failure. Add a tracing product when shared IDs no longer explain cross-service latency.&lt;/p&gt;

&lt;p&gt;Stick with Datadog when your team has deliberately chosen its wider operational workflow and wants logging evaluated inside that choice. Keep Logtail in the focused comparison when your priority remains centralized logs. Choose another hosted log API only after its query contract, retry behavior, retention controls, and data lifecycle match the workload. The generic label hides substantial differences.&lt;/p&gt;

&lt;p&gt;And don't force a lightweight log destination into a compliance system. User-level deletion, bulk export, subscriptions, auditability, and configurable retention are requirements, not polish. If any one is mandatory, verify it before sending production data. This is the objection I hear most from platform engineers, and they're right to raise it early.&lt;/p&gt;

&lt;p&gt;The other objection is portability. An application-level Pino schema helps, but portability isn't automatic; saved searches, alerts, dashboards, and retention rules can still bind a team to a destination. Keep the first contract small, name fields consistently, and isolate transport code. It won't erase migration work, but it protects the Express handlers from most of it.&lt;/p&gt;

&lt;p&gt;Start small. Stay explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://getpino.io/" rel="noopener noreferrer"&gt;https://getpino.io/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://betterstack.com/docs/logs/" rel="noopener noreferrer"&gt;https://betterstack.com/docs/logs/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.datadoghq.com/logs/" rel="noopener noreferrer"&gt;https://docs.datadoghq.com/logs/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://healthchecks.io/docs/" rel="noopener noreferrer"&gt;https://healthchecks.io/docs/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://opentelemetry.io/docs/concepts/signals/traces/" rel="noopener noreferrer"&gt;https://opentelemetry.io/docs/concepts/signals/traces/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>express</category>
      <category>logging</category>
    </item>
  </channel>
</rss>
