<?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: Martin Palopoli</title>
    <description>The latest articles on DEV Community by Martin Palopoli (@martin_palopoli).</description>
    <link>https://dev.to/martin_palopoli</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%2F1593262%2F1f1a00c6-c7c8-4524-9741-0cc1ae77f741.JPG</url>
      <title>DEV Community: Martin Palopoli</title>
      <link>https://dev.to/martin_palopoli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martin_palopoli"/>
    <language>en</language>
    <item>
      <title>Tracing, métricas Prometheus y logs estructurados con dos decoradores: Fitz vs el setup de OpenTelemetry en FastAPI</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 30 Jun 2026 10:28:54 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/tracing-metricas-prometheus-y-logs-estructurados-con-dos-decoradores-fitz-vs-el-setup-de-4143</link>
      <guid>https://dev.to/martin_palopoli/tracing-metricas-prometheus-y-logs-estructurados-con-dos-decoradores-fitz-vs-el-setup-de-4143</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Para tener observability completa en FastAPI necesitás 6 paquetes pip + 60 líneas de config + glue manual entre logs/spans/métricas. En Fitz son dos decoradores y un env var. Con trace_id correlacionado auto entre logs y spans, y &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; redactado en logs sin pensar.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  El stack que toda app "production-ready" termina pegoteando
&lt;/h2&gt;

&lt;p&gt;Tu app crece. El cliente quiere saber qué endpoint está lento, cuántos requests fallaron en la última hora, y por qué un user específico vio un error a las 3 AM. Hablamos del Triángulo Sagrado de observability: &lt;strong&gt;traces, metrics, logs&lt;/strong&gt;. En 2026 la respuesta de la industria es OpenTelemetry para los tres.&lt;/p&gt;

&lt;p&gt;En Python con FastAPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;opentelemetry-distro[otlp] &lt;span class="se"&gt;\&lt;/span&gt;
            opentelemetry-instrumentation-fastapi &lt;span class="se"&gt;\&lt;/span&gt;
            opentelemetry-instrumentation-sqlalchemy &lt;span class="se"&gt;\&lt;/span&gt;
            opentelemetry-instrumentation-requests &lt;span class="se"&gt;\&lt;/span&gt;
            opentelemetry-exporter-otlp-proto-grpc &lt;span class="se"&gt;\&lt;/span&gt;
            prometheus-fastapi-instrumentator &lt;span class="se"&gt;\&lt;/span&gt;
            structlog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;observability.py&lt;/code&gt; (~60 líneas):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.resources&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Resource&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.trace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TracerProvider&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.trace.export&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BatchSpanProcessor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.metrics&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MeterProvider&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.metrics.export&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PeriodicExportingMetricReader&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.exporter.otlp.proto.grpc.trace_exporter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OTLPSpanExporter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.exporter.otlp.proto.grpc.metric_exporter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OTLPMetricExporter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.instrumentation.fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPIInstrumentor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.instrumentation.sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SQLAlchemyInstrumentor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.trace.sampling&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TraceIdRatioBased&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;prometheus_fastapi_instrumentator&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Instrumentator&lt;/span&gt;

&lt;span class="n"&gt;SERVICE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OTEL_SERVICE_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;OTLP_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SAMPLE_RATIO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OTEL_TRACES_SAMPLER_ARG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setup_observability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Resource&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service.name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SERVICE_NAME&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;OTLP_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;trace_provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TracerProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;sampler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;TraceIdRatioBased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAMPLE_RATIO&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;trace_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_span_processor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;BatchSpanProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OTLPSpanExporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;OTLP_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_tracer_provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace_provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;metric_reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PeriodicExportingMetricReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;OTLPMetricExporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;OTLP_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;meter_provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MeterProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metric_readers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;metric_reader&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_meter_provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meter_provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;FastAPIInstrumentor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instrument_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;SQLAlchemyInstrumentor&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;instrument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;Instrumentator&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;instrument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;expose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Logs estructurados con trace_id
&lt;/span&gt;    &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contextvars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merge_contextvars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_log_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TimeStamper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dict_tracebacks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;inject_trace_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# propio, ver abajo
&lt;/span&gt;            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JSONRenderer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;wrapper_class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;make_filtering_bound_logger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;cache_logger_on_first_use&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;inject_trace_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_current_span&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_span_context&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;is_valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_span_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;event_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace_id&lt;/span&gt;&lt;span class="sh"&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;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;032x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;event_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span_id&lt;/span&gt;&lt;span class="sh"&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;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;016x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;event_dict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus uso en handlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;

&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_logger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_tracer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;meter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_meter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;orders_counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_calls_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;orders_histogram&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_histogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_duration_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderIn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&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="n"&gt;log&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.processing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;receipt&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;actually_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;orders_counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="n"&gt;log&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.processed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;orders_counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="n"&gt;log&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;orders_histogram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ocho instalaciones de paquetes. ~60 líneas de setup. ~25 líneas por handler para tracear + metricar + loguear. Conexión manual entre las tres signals.&lt;/p&gt;

&lt;p&gt;Y ojo con: si te olvidás de llamar &lt;code&gt;FastAPIInstrumentor.instrument_app(app)&lt;/code&gt;, no hay spans HTTP. Si te olvidás del &lt;code&gt;SQLAlchemyInstrumentor&lt;/code&gt;, no se ve la DB. Si &lt;code&gt;structlog&lt;/code&gt; no tiene el processor &lt;code&gt;inject_trace_context&lt;/code&gt;, los logs no se correlacionan con los spans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo mismo en Fitz
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, prometheus=true)
fn main() =&amp;gt; 0

@trace(name="process_order")
@metric(name="orders")
async fn process_order(body: OrderIn) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    log.info("order.processing", { order_id: body.id, total: body.total })
    let receipt = actually_process(body).await?
    log.info("order.processed", { receipt_id: receipt.id })
    return Ok(receipt)
}

@post("/orders")
async fn create_order(body: OrderIn) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    return process_order(body).await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Activar el export OTLP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318
fitz run main.fitz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso es todo.&lt;/p&gt;

&lt;h2&gt;
  
  
  La tabla cruda
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Python (OTel + structlog + 6 libs)&lt;/th&gt;
&lt;th&gt;Fitz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup inicial&lt;/td&gt;
&lt;td&gt;~60 LoC + 6 instalaciones pip&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@server(prometheus=true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Span HTTP por request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FastAPIInstrumentor.instrument_app(app)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-instrumented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Span custom sobre fn&lt;/td&gt;
&lt;td&gt;&lt;code&gt;with tracer.start_as_current_span("X")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@trace(name="X")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Counter de calls&lt;/td&gt;
&lt;td&gt;&lt;code&gt;meter.create_counter(...) + .add(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@metric(name="X")&lt;/code&gt; (incluye duration histogram)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Histogram de duration&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;meter.create_histogram(...) + .record(...)&lt;/code&gt; + try/finally&lt;/td&gt;
&lt;td&gt;Mismo &lt;code&gt;@metric&lt;/code&gt; (RAII guard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint &lt;code&gt;/metrics&lt;/code&gt; Prometheus&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Instrumentator().instrument(app).expose(app)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@server(prometheus=true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs estructurados JSON&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;structlog.configure(...)&lt;/code&gt; con 6 processors&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;log.info("event", { ... })&lt;/code&gt; built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace_id propagado a logs&lt;/td&gt;
&lt;td&gt;Processor custom &lt;code&gt;inject_trace_context&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Automático (task-local)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret redactado en logs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.get_secret_value()&lt;/code&gt; manual con cuidado&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; se redacta auto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP access log&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FastAPIInstrumentor&lt;/code&gt; lo emite&lt;/td&gt;
&lt;td&gt;Auto-emitido con &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sampling tail-based&lt;/td&gt;
&lt;td&gt;TraceIdRatioBased&lt;/td&gt;
&lt;td&gt;TraceIdRatioBased (mismo)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Por partes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Spans HTTP auto-instrumentados
&lt;/h3&gt;

&lt;p&gt;En Fitz, cada &lt;code&gt;@get&lt;/code&gt;/&lt;code&gt;@post&lt;/code&gt;/&lt;code&gt;@put&lt;/code&gt;/&lt;code&gt;@delete&lt;/code&gt;/&lt;code&gt;@ws&lt;/code&gt; abre un span OTel con:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http.method&lt;/code&gt; (GET/POST/...)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http.target&lt;/code&gt; (el path &lt;strong&gt;template&lt;/strong&gt; — &lt;code&gt;/users/{id}&lt;/code&gt; no &lt;code&gt;/users/42&lt;/code&gt;, low cardinality friendly)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http.status_code&lt;/code&gt; al cerrar el span&lt;/li&gt;
&lt;li&gt;&lt;code&gt;duration_ms&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Y emite un access log con los mismos campos + &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt;. Sin código del user.&lt;/p&gt;

&lt;p&gt;En Python tenés que llamar &lt;code&gt;FastAPIInstrumentor.instrument_app(app)&lt;/code&gt; y rezar que tu versión matchea. Si tu user agent emite headers con caracteres no-ASCII, la version vieja de la lib panickeaba — bug famoso.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trace_id propagado a logs custom
&lt;/h3&gt;

&lt;p&gt;Adentro del span del request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@authenticated @post("/orders")
async fn create_order(user: User, body: OrderIn) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    log.info("order.received", { order_id: body.id, user_email: user.email })
    let receipt = process(body).await?
    log.info("order.ack", { receipt_id: receipt.id })
    return Ok(receipt)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cada &lt;code&gt;log.info(...)&lt;/code&gt; adentro del handler incluye automáticamente:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-16T10:23:01.231Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"order.received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5e4f9b2c8a7d3e1f0b6c9a4d8e2f1a3b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3d4e5f6a7b8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ada@example.com"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;trace_id&lt;/code&gt; matchea con el &lt;code&gt;trace_id&lt;/code&gt; del span en Jaeger/Tempo. &lt;strong&gt;Por&lt;/strong&gt; &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;la cierre 9.x.4 iter2.a&lt;/a&gt;, cuando hay OTel activo, el &lt;code&gt;trace_id&lt;/code&gt; de los logs &lt;strong&gt;es exactamente el mismo&lt;/strong&gt; del span OTel — habilita queries cross-pipeline ("dame todos los logs cuyo trace_id coincide con este span de Jaeger").&lt;/p&gt;

&lt;p&gt;En Python tenés que escribir el processor &lt;code&gt;inject_trace_context&lt;/code&gt; a mano (~10 líneas), agregarlo a la config de structlog, y validar que cada handler usa structlog en lugar del &lt;code&gt;logging&lt;/code&gt; stdlib (porque si alguien hace &lt;code&gt;import logging; logging.info(...)&lt;/code&gt; directamente, los logs NO van a tener el trace_id).&lt;/p&gt;

&lt;h3&gt;
  
  
  Métricas con un decorador
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@metric(name="orders")&lt;/code&gt; automáticamente registra DOS metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;orders_calls_total&lt;/code&gt;&lt;/strong&gt; — Counter, incrementado al return de la fn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;orders_duration_seconds&lt;/code&gt;&lt;/strong&gt; — Histogram, registrado al return (incluso si la fn paniquea, por RAII guard).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    // process_order span se cierra al return
    // orders_calls_total += 1
    // orders_duration_seconds.observe(elapsed)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Python con OTel, para el mismo efecto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;orders_counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_calls_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;orders_histogram&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_histogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_duration_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@tracer.start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Receipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&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="n"&gt;result&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;actually_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;orders_counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;orders_histogram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5× más código. Y si te olvidás el &lt;code&gt;finally&lt;/code&gt;, la histogram pierde casos. Decoradores de Fitz garantizan el cleanup vía RAII.&lt;/p&gt;

&lt;h3&gt;
  
  
  Endpoint Prometheus opcional
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, prometheus=true)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Auto-monta &lt;code&gt;GET /metrics&lt;/code&gt; en el mismo puerto, devolviendo el formato exposition de Prometheus. Sin librería separada. Sin definir el endpoint a mano.&lt;/p&gt;

&lt;p&gt;Si el user declaró su propio &lt;code&gt;@get("/metrics")&lt;/code&gt;, gana — mismo patrón que &lt;code&gt;/openapi.json&lt;/code&gt;/&lt;code&gt;/healthz&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;En Python: &lt;code&gt;Instrumentator().instrument(app).expose(app)&lt;/code&gt; — está bien, pero es otra dep, otra responsabilidad, otra versión a matchear con FastAPI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Export OTLP con un env var
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Para Jaeger&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318

&lt;span class="c"&gt;# Para Honeycomb (con headers)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.honeycomb.io
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_HEADERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;x-honeycomb-team&lt;span class="o"&gt;=&lt;/span&gt;abc123

&lt;span class="c"&gt;# Para Tempo&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://tempo:4318

&lt;span class="c"&gt;# Tail-based sampling 10%&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_TRACES_SAMPLER_ARG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.1

&lt;span class="c"&gt;# Service name&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_SERVICE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sin la env var, &lt;strong&gt;cero overhead, cero conexiones de red&lt;/strong&gt;. El instrumento corre como no-op si no hay endpoint declarado.&lt;/p&gt;

&lt;p&gt;Estos env vars son los &lt;strong&gt;standard de OpenTelemetry&lt;/strong&gt;, no inventados por Fitz. Compatible con cualquier backend OTel (Jaeger, Tempo, Honeycomb, Datadog, NewRelic, Grafana Cloud, etc.).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; redactado auto en logs
&lt;/h3&gt;

&lt;p&gt;Esta es mi feature favorita. En Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;log&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth.success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ¡bug! va el token a Loki
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El bug que más vi en código de producción: alguien loguea la API key, la password, el JWT. El secret va a Loki/Sentry/Datadog. Borrar logs de producción es una operación lenta y dolorosa.&lt;/p&gt;

&lt;p&gt;En Fitz, &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; redacta automáticamente en &lt;code&gt;Display&lt;/code&gt; y en serialización JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")
let user_token: Secret&amp;lt;Str&amp;gt; = generate_token(user)

log.info("auth.success", { token: user_token })
// → {"msg": "auth.success", "token": "***"}

print("JWT_SECRET = {JWT_SECRET}")
// → "JWT_SECRET = ***"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para exponer el valor real (al firmar JWT, al pegar a la DB):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let token = jwt.encode(claims, JWT_SECRET.expose())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.expose()&lt;/code&gt; es explícito, grepeable. El code review puede auditar cada call site en segundos.&lt;/p&gt;

&lt;p&gt;En Pydantic existe &lt;code&gt;SecretStr&lt;/code&gt; pero tenés que recordar opt-in en cada lugar, y la gente se olvida. Fitz hace que la versión segura sea la default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisiones de diseño que vale la pena entender
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;@trace&lt;/code&gt;/&lt;code&gt;@metric&lt;/code&gt; solo sobre fns user, no sobre HTTP/WS
&lt;/h3&gt;

&lt;p&gt;Los handlers HTTP y WebSocket ya tienen auto-instrumentation con el span del request. Stackear &lt;code&gt;@trace&lt;/code&gt; arriba de &lt;code&gt;@get&lt;/code&gt; sería redundante y crearía spans anidados sin valor. El checker lo rechaza con mensaje claro.&lt;/p&gt;

&lt;h3&gt;
  
  
  Acceso log auto-emitido
&lt;/h3&gt;

&lt;p&gt;Cada handler HTTP emite un &lt;code&gt;log.info("http.access", ...)&lt;/code&gt; al return con &lt;code&gt;http.method&lt;/code&gt;/&lt;code&gt;http.target&lt;/code&gt;/&lt;code&gt;http.status_code&lt;/code&gt;/&lt;code&gt;duration_ms&lt;/code&gt;. Sin opt-in. Si querés desactivarlo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, observability=false)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Y el wrapper de instrumentación se bypassea entero.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage del span context con task-local
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;SpanContext&lt;/code&gt; vive en un &lt;code&gt;tokio::task_local!&lt;/code&gt;. Atraviesa thread boundaries en runtime multi-thread. Sin globales mutables, sin race conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que Fitz NO te da (todavía)
&lt;/h2&gt;

&lt;p&gt;Honestidad sobre las deudas residuales de Fase 12.3 documentadas explícitamente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bridge logs OTel&lt;/strong&gt;: los &lt;code&gt;log.X(...)&lt;/code&gt; van a stderr (con &lt;code&gt;trace_id&lt;/code&gt; propagado). Para que ALSO vayan al log signal de OTel del backend (correlacionado con spans &lt;strong&gt;ahí&lt;/strong&gt;), tenés que esperar al sub-paso 12.3.iter2.b — diseñado pero no shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bridge métricas OTel&lt;/strong&gt;: las metrics que emite &lt;code&gt;@metric&lt;/code&gt; despachan al recorder Prometheus cuando &lt;code&gt;@server(prometheus=true)&lt;/code&gt;. Para que también vayan al OTel metrics signal (push a Honeycomb metrics, NewRelic metrics) hay que esperar release del crate upstream &lt;code&gt;metrics-exporter-opentelemetry&lt;/code&gt; compatible con &lt;code&gt;opentelemetry_sdk 0.32&lt;/code&gt; (deuda documentada, no por desidia nuestra).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sampling tail-based&lt;/strong&gt;: solo head-based (&lt;code&gt;TraceIdRatioBased&lt;/code&gt;). Para "exportá traces que tuvieron error" o "samplealos por latencia" hay que correr OTel collector en el medio con la config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profiling continuo&lt;/strong&gt; (pyroscope/pprof) — no integrado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-instrumentation de DB&lt;/strong&gt;: el ORM nativo emite el SQL ejecutado en los spans del handler que lo llama. Para queries vía &lt;code&gt;db.query(...)&lt;/code&gt; raw, hoy no se crea span hijo (deuda menor).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cierre
&lt;/h2&gt;

&lt;p&gt;Observability en Python es el área donde más visiblemente paga "ser library-first": cada signal vive en una lib distinta, cada lib tiene su propia config, cada handler necesita boilerplate para usarlas, y mantener consistencia entre las tres signals es responsabilidad tuya.&lt;/p&gt;

&lt;p&gt;Fitz mete las tres signals en el lenguaje, con auto-instrumentation para lo que toda app necesita (HTTP spans + access logs + metrics), decoradores opcionales para lo custom (&lt;code&gt;@trace&lt;/code&gt;/&lt;code&gt;@metric&lt;/code&gt;), y conexión nativa entre signals (trace_id propagado, Secret redactado).&lt;/p&gt;

&lt;p&gt;El target sigue siendo el ecosistema OTel — exportás a los mismos backends, con los mismos env vars estándar. Lo que cambia es el código tuyo.&lt;/p&gt;

&lt;p&gt;Si tu pipeline de observability ya está estabilizado con OTel en FastAPI y funciona, no hay urgencia. Si estás arrancando un proyecto en 2026 y querés evitar el día de plumbing, vale la prueba.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Próximo post de la serie&lt;/strong&gt;: &lt;strong&gt;"ORM tipado con migraciones automáticas: Fitz vs SQLAlchemy + Alembic + Pydantic"&lt;/strong&gt; — la última pieza del stack, con benchmarks reproducibles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Capítulo 33 de la guía&lt;/strong&gt; (Observability): &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;docs/guide.md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Tracing, Prometheus metrics, and structured logs with two decorators: Fitz vs the OpenTelemetry setup in FastAPI</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Tue, 30 Jun 2026 10:28:39 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/tracing-prometheus-metrics-and-structured-logs-with-two-decorators-fitz-vs-the-opentelemetry-3ldk</link>
      <guid>https://dev.to/martin_palopoli/tracing-prometheus-metrics-and-structured-logs-with-two-decorators-fitz-vs-the-opentelemetry-3ldk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;For full observability in FastAPI you need 6 pip packages + 60 lines of config + manual glue between logs/spans/metrics. In Fitz it's two decorators and an env var. With trace_id auto-correlated between logs and spans, and &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; redacted in logs without thinking.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The stack every "production-ready" app ends up gluing together
&lt;/h2&gt;

&lt;p&gt;Your app grows. The client wants to know which endpoint is slow, how many requests failed in the last hour, and why a specific user saw an error at 3 AM. We're talking about the Sacred Triangle of observability: &lt;strong&gt;traces, metrics, logs&lt;/strong&gt;. In 2026 the industry answer is OpenTelemetry for all three.&lt;/p&gt;

&lt;p&gt;In Python with FastAPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;opentelemetry-distro[otlp] &lt;span class="se"&gt;\&lt;/span&gt;
            opentelemetry-instrumentation-fastapi &lt;span class="se"&gt;\&lt;/span&gt;
            opentelemetry-instrumentation-sqlalchemy &lt;span class="se"&gt;\&lt;/span&gt;
            opentelemetry-instrumentation-requests &lt;span class="se"&gt;\&lt;/span&gt;
            opentelemetry-exporter-otlp-proto-grpc &lt;span class="se"&gt;\&lt;/span&gt;
            prometheus-fastapi-instrumentator &lt;span class="se"&gt;\&lt;/span&gt;
            structlog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;observability.py&lt;/code&gt; (~60 lines):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.resources&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Resource&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.trace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TracerProvider&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.trace.export&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BatchSpanProcessor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.metrics&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MeterProvider&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.metrics.export&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PeriodicExportingMetricReader&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.exporter.otlp.proto.grpc.trace_exporter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OTLPSpanExporter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.exporter.otlp.proto.grpc.metric_exporter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OTLPMetricExporter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.instrumentation.fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPIInstrumentor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.instrumentation.sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SQLAlchemyInstrumentor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.sdk.trace.sampling&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TraceIdRatioBased&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;prometheus_fastapi_instrumentator&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Instrumentator&lt;/span&gt;

&lt;span class="n"&gt;SERVICE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OTEL_SERVICE_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;OTLP_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SAMPLE_RATIO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OTEL_TRACES_SAMPLER_ARG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setup_observability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Resource&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service.name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SERVICE_NAME&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;OTLP_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;trace_provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TracerProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;sampler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;TraceIdRatioBased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAMPLE_RATIO&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;trace_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_span_processor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;BatchSpanProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OTLPSpanExporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;OTLP_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_tracer_provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace_provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;metric_reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PeriodicExportingMetricReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;OTLPMetricExporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;OTLP_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;meter_provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MeterProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metric_readers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;metric_reader&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_meter_provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meter_provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;FastAPIInstrumentor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instrument_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;SQLAlchemyInstrumentor&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;instrument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;Instrumentator&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;instrument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;expose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Structured logs with trace_id
&lt;/span&gt;    &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contextvars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merge_contextvars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_log_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TimeStamper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iso&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dict_tracebacks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;inject_trace_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# custom, see below
&lt;/span&gt;            &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JSONRenderer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;wrapper_class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;make_filtering_bound_logger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;cache_logger_on_first_use&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;inject_trace_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_current_span&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_span_context&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;is_valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_span_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;event_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace_id&lt;/span&gt;&lt;span class="sh"&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;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;032x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;event_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span_id&lt;/span&gt;&lt;span class="sh"&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;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;016x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;event_dict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus usage in handlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;

&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;structlog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_logger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_tracer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;meter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_meter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;orders_counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_calls_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;orders_histogram&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_histogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_duration_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderIn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&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="n"&gt;log&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.processing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;receipt&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;actually_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;orders_counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="n"&gt;log&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.processed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;orders_counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="n"&gt;log&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;orders_histogram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight pip installs. ~60 lines of setup. ~25 lines per handler to trace + meter + log. Manual connection between the three signals.&lt;/p&gt;

&lt;p&gt;And watch out: if you forget to call &lt;code&gt;FastAPIInstrumentor.instrument_app(app)&lt;/code&gt;, no HTTP spans. If you forget &lt;code&gt;SQLAlchemyInstrumentor&lt;/code&gt;, the DB doesn't show. If &lt;code&gt;structlog&lt;/code&gt; doesn't have the &lt;code&gt;inject_trace_context&lt;/code&gt; processor, logs won't correlate with spans.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing in Fitz
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, prometheus=true)
fn main() =&amp;gt; 0

@trace(name="process_order")
@metric(name="orders")
async fn process_order(body: OrderIn) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    log.info("order.processing", { order_id: body.id, total: body.total })
    let receipt = actually_process(body).await?
    log.info("order.processed", { receipt_id: receipt.id })
    return Ok(receipt)
}

@post("/orders")
async fn create_order(body: OrderIn) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    return process_order(body).await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Activate OTLP export:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318
fitz run main.fitz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  The raw table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Python (OTel + structlog + 6 libs)&lt;/th&gt;
&lt;th&gt;Fitz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Initial setup&lt;/td&gt;
&lt;td&gt;~60 LoC + 6 pip installs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@server(prometheus=true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP span per request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FastAPIInstrumentor.instrument_app(app)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-instrumented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom span over fn&lt;/td&gt;
&lt;td&gt;&lt;code&gt;with tracer.start_as_current_span("X")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@trace(name="X")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Call counter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;meter.create_counter(...) + .add(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@metric(name="X")&lt;/code&gt; (includes duration histogram)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duration histogram&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;meter.create_histogram(...) + .record(...)&lt;/code&gt; + try/finally&lt;/td&gt;
&lt;td&gt;Same &lt;code&gt;@metric&lt;/code&gt; (RAII guard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus &lt;code&gt;/metrics&lt;/code&gt; endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Instrumentator().instrument(app).expose(app)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@server(prometheus=true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured JSON logs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;structlog.configure(...)&lt;/code&gt; with 6 processors&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;log.info("event", { ... })&lt;/code&gt; built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace_id propagated to logs&lt;/td&gt;
&lt;td&gt;Custom &lt;code&gt;inject_trace_context&lt;/code&gt; processor&lt;/td&gt;
&lt;td&gt;Automatic (task-local)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret redacted in logs&lt;/td&gt;
&lt;td&gt;Manual &lt;code&gt;.get_secret_value()&lt;/code&gt; with care&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; redacts auto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP access log&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FastAPIInstrumentor&lt;/code&gt; emits it&lt;/td&gt;
&lt;td&gt;Auto-emitted with &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tail-based sampling&lt;/td&gt;
&lt;td&gt;TraceIdRatioBased&lt;/td&gt;
&lt;td&gt;TraceIdRatioBased (same)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Piece by piece
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Auto-instrumented HTTP spans
&lt;/h3&gt;

&lt;p&gt;In Fitz, every &lt;code&gt;@get&lt;/code&gt;/&lt;code&gt;@post&lt;/code&gt;/&lt;code&gt;@put&lt;/code&gt;/&lt;code&gt;@delete&lt;/code&gt;/&lt;code&gt;@ws&lt;/code&gt; opens an OTel span with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http.method&lt;/code&gt; (GET/POST/...)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http.target&lt;/code&gt; (the path &lt;strong&gt;template&lt;/strong&gt; — &lt;code&gt;/users/{id}&lt;/code&gt; not &lt;code&gt;/users/42&lt;/code&gt;, low-cardinality friendly)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http.status_code&lt;/code&gt; when the span closes&lt;/li&gt;
&lt;li&gt;&lt;code&gt;duration_ms&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it emits an access log with the same fields + &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt;. Zero user code.&lt;/p&gt;

&lt;p&gt;In Python you have to call &lt;code&gt;FastAPIInstrumentor.instrument_app(app)&lt;/code&gt; and hope your version matches. If your user agent emits headers with non-ASCII characters, the old version of the lib used to panic — famous bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trace_id propagated to custom logs
&lt;/h3&gt;

&lt;p&gt;Inside the request's span:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@authenticated @post("/orders")
async fn create_order(user: User, body: OrderIn) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    log.info("order.received", { order_id: body.id, user_email: user.email })
    let receipt = process(body).await?
    log.info("order.ack", { receipt_id: receipt.id })
    return Ok(receipt)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;log.info(...)&lt;/code&gt; inside the handler automatically includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-16T10:23:01.231Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"order.received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5e4f9b2c8a7d3e1f0b6c9a4d8e2f1a3b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3d4e5f6a7b8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ada@example.com"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;trace_id&lt;/code&gt; matches the &lt;code&gt;trace_id&lt;/code&gt; of the span in Jaeger/Tempo. &lt;strong&gt;By&lt;/strong&gt; &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;the 9.x.4 iter2.a close&lt;/a&gt;, when OTel is active, the &lt;code&gt;trace_id&lt;/code&gt; in the logs &lt;strong&gt;is the exact same one&lt;/strong&gt; as the OTel span — enabling cross-pipeline queries ("give me all logs whose trace_id matches this Jaeger span").&lt;/p&gt;

&lt;p&gt;In Python you have to write the &lt;code&gt;inject_trace_context&lt;/code&gt; processor by hand (~10 lines), add it to structlog's config, and validate that every handler uses structlog instead of the &lt;code&gt;logging&lt;/code&gt; stdlib (because if anyone does &lt;code&gt;import logging; logging.info(...)&lt;/code&gt; directly, those logs will NOT have the trace_id).&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics with one decorator
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@metric(name="orders")&lt;/code&gt; automatically registers TWO metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;orders_calls_total&lt;/code&gt;&lt;/strong&gt; — Counter, incremented at fn return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;orders_duration_seconds&lt;/code&gt;&lt;/strong&gt; — Histogram, recorded at return (even if the fn panics, via RAII guard).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    // process_order span closes at return
    // orders_calls_total += 1
    // orders_duration_seconds.observe(elapsed)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Python with OTel, for the same effect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;orders_counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_calls_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;orders_histogram&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_histogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_duration_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@tracer.start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Receipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&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="n"&gt;result&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;actually_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;orders_counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;orders_histogram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5× more code. And if you forget the &lt;code&gt;finally&lt;/code&gt;, the histogram loses cases. Fitz's decorators guarantee cleanup via RAII.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optional Prometheus endpoint
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, prometheus=true)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Auto-mounts &lt;code&gt;GET /metrics&lt;/code&gt; on the same port, returning the Prometheus exposition format. No separate library. No defining the endpoint by hand.&lt;/p&gt;

&lt;p&gt;If the user declared their own &lt;code&gt;@get("/metrics")&lt;/code&gt;, theirs wins — same pattern as &lt;code&gt;/openapi.json&lt;/code&gt;/&lt;code&gt;/healthz&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Python: &lt;code&gt;Instrumentator().instrument(app).expose(app)&lt;/code&gt; — it's fine, but it's another dep, another responsibility, another version to match with FastAPI.&lt;/p&gt;

&lt;h3&gt;
  
  
  OTLP export with one env var
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# For Jaeger&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318

&lt;span class="c"&gt;# For Honeycomb (with headers)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.honeycomb.io
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_HEADERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;x-honeycomb-team&lt;span class="o"&gt;=&lt;/span&gt;abc123

&lt;span class="c"&gt;# For Tempo&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://tempo:4318

&lt;span class="c"&gt;# Tail-based sampling 10%&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_TRACES_SAMPLER_ARG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.1

&lt;span class="c"&gt;# Service name&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_SERVICE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the env var, &lt;strong&gt;zero overhead, zero network calls&lt;/strong&gt;. The instrument runs as a no-op if no endpoint is declared.&lt;/p&gt;

&lt;p&gt;These env vars are the &lt;strong&gt;OpenTelemetry standard&lt;/strong&gt;, not invented by Fitz. Compatible with any OTel backend (Jaeger, Tempo, Honeycomb, Datadog, NewRelic, Grafana Cloud, etc.).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; auto-redacted in logs
&lt;/h3&gt;

&lt;p&gt;This is my favorite feature. In Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;log&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth.success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# bug! token goes to Loki
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug I've seen most in production code: someone logs the API key, the password, the JWT. The secret goes to Loki/Sentry/Datadog. Deleting production logs is a slow and painful operation.&lt;/p&gt;

&lt;p&gt;In Fitz, &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; redacts automatically in &lt;code&gt;Display&lt;/code&gt; and JSON serialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")
let user_token: Secret&amp;lt;Str&amp;gt; = generate_token(user)

log.info("auth.success", { token: user_token })
// → {"msg": "auth.success", "token": "***"}

print("JWT_SECRET = {JWT_SECRET}")
// → "JWT_SECRET = ***"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To expose the real value (when signing JWT, when hitting the DB):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let token = jwt.encode(claims, JWT_SECRET.expose())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.expose()&lt;/code&gt; is explicit, greppable. Code review can audit each call site in seconds.&lt;/p&gt;

&lt;p&gt;In Pydantic there's &lt;code&gt;SecretStr&lt;/code&gt; but you have to remember to opt in at every place, and people forget. Fitz makes the safe version the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions worth understanding
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;@trace&lt;/code&gt;/&lt;code&gt;@metric&lt;/code&gt; only on user fns, not on HTTP/WS
&lt;/h3&gt;

&lt;p&gt;HTTP and WebSocket handlers already have auto-instrumentation with the request span. Stacking &lt;code&gt;@trace&lt;/code&gt; on top of &lt;code&gt;@get&lt;/code&gt; would be redundant and would create nested spans with no value. The checker rejects it with a clear message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auto-emitted access log
&lt;/h3&gt;

&lt;p&gt;Every HTTP handler emits a &lt;code&gt;log.info("http.access", ...)&lt;/code&gt; on return with &lt;code&gt;http.method&lt;/code&gt;/&lt;code&gt;http.target&lt;/code&gt;/&lt;code&gt;http.status_code&lt;/code&gt;/&lt;code&gt;duration_ms&lt;/code&gt;. No opt-in. If you want to disable it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, observability=false)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the instrumentation wrapper is bypassed entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Span context storage with task-local
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;SpanContext&lt;/code&gt; lives in a &lt;code&gt;tokio::task_local!&lt;/code&gt;. Crosses thread boundaries in multi-thread runtime. No mutable globals, no race conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fitz does NOT give you (yet)
&lt;/h2&gt;

&lt;p&gt;Honesty about the residual debts from Phase 12.3 documented explicitly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OTel logs bridge&lt;/strong&gt;: &lt;code&gt;log.X(...)&lt;/code&gt; go to stderr (with &lt;code&gt;trace_id&lt;/code&gt; propagated). For them to ALSO go to the OTel log signal of the backend (correlated with spans &lt;strong&gt;there&lt;/strong&gt;), you have to wait for sub-step 12.3.iter2.b — designed but not shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OTel metrics bridge&lt;/strong&gt;: the metrics that &lt;code&gt;@metric&lt;/code&gt; emits dispatch to the Prometheus recorder when &lt;code&gt;@server(prometheus=true)&lt;/code&gt;. For them to also go to the OTel metrics signal (push to Honeycomb metrics, NewRelic metrics) you need to wait for the upstream crate &lt;code&gt;metrics-exporter-opentelemetry&lt;/code&gt; release compatible with &lt;code&gt;opentelemetry_sdk 0.32&lt;/code&gt; (documented debt, not laziness on our part).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tail-based sampling&lt;/strong&gt;: only head-based (&lt;code&gt;TraceIdRatioBased&lt;/code&gt;). For "export traces that had errors" or "sample by latency" you have to run OTel collector in the middle with that config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous profiling&lt;/strong&gt; (pyroscope/pprof) — not integrated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB auto-instrumentation&lt;/strong&gt;: the native ORM emits the executed SQL inside the calling handler's span. For queries via raw &lt;code&gt;db.query(...)&lt;/code&gt;, today no child span is created (minor debt).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Observability in Python is the area where "library-first" most visibly pays off poorly: each signal lives in a different lib, each lib has its own config, each handler needs boilerplate to use them, and keeping consistency across the three signals is your responsibility.&lt;/p&gt;

&lt;p&gt;Fitz puts the three signals in the language, with auto-instrumentation for what every app needs (HTTP spans + access logs + metrics), optional decorators for custom things (&lt;code&gt;@trace&lt;/code&gt;/&lt;code&gt;@metric&lt;/code&gt;), and native connection between signals (trace_id propagated, Secret redacted).&lt;/p&gt;

&lt;p&gt;The target remains the OTel ecosystem — you export to the same backends, with the same standard env vars. What changes is your code.&lt;/p&gt;

&lt;p&gt;If your observability pipeline is already stabilized with OTel in FastAPI and works, there's no urgency. If you're starting a project in 2026 and want to avoid the day of plumbing, it's worth a try.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next post in the series&lt;/strong&gt;: &lt;strong&gt;"Typed ORM with automatic migrations: Fitz vs SQLAlchemy + Alembic + Pydantic"&lt;/strong&gt; — the last piece of the stack, with reproducible benchmarks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Chapter 33 of the guide&lt;/strong&gt; (Observability): &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;docs/guide.md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Buscando 5 beta testers para FitzWatch — el status page que construí en Fitz</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Mon, 29 Jun 2026 12:19:57 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/buscando-5-beta-testers-para-fitzwatch-el-status-page-que-construi-en-fitz-22j2</link>
      <guid>https://dev.to/martin_palopoli/buscando-5-beta-testers-para-fitzwatch-el-status-page-que-construi-en-fitz-22j2</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Construí &lt;strong&gt;fitzwatch&lt;/strong&gt; (&lt;a href="https://www.fitzwatch.com" rel="noopener noreferrer"&gt;https://www.fitzwatch.com&lt;/a&gt;), un status page para&lt;br&gt;
apps en prod estilo Statuspage de Atlassian pero más liviano y barato.&lt;br&gt;
El producto está vivo y operacional. Antes de abrirlo al público y&lt;br&gt;
sumar planes pagos, busco &lt;strong&gt;5 beta testers&lt;/strong&gt; para validar el producto&lt;br&gt;
con users externos.&lt;/p&gt;

&lt;p&gt;Si tenés algo en prod que monitorees (o que deberías monitorear) y te&lt;br&gt;
interesa probar algo nuevo + dar feedback directo, leé abajo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué hace fitzwatch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Monitorea endpoints HTTP/TCP cada 10s.&lt;/li&gt;
&lt;li&gt;Dispara incidents con lifecycle (investigating → identified → monitoring → resolved).&lt;/li&gt;
&lt;li&gt;Status page público scoped por slug &lt;code&gt;/p/&amp;lt;tu-slug&amp;gt;/&lt;/code&gt; con branding propio (logo, color, nombre).&lt;/li&gt;
&lt;li&gt;Notificaciones por email y webhook cuando algo se cae.&lt;/li&gt;
&lt;li&gt;RSS feed + widget embebible para tu site.&lt;/li&gt;
&lt;li&gt;90-day uptime chart, subscribers públicos, multi-tenant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Comparado con &lt;a href="https://www.atlassian.com/software/statuspage" rel="noopener noreferrer"&gt;Statuspage&lt;/a&gt;&lt;br&gt;
(~$29/mes) o &lt;a href="https://betterstack.com/" rel="noopener noreferrer"&gt;Better Stack&lt;/a&gt; (~$24/mes), apunta&lt;br&gt;
al sweet spot del &lt;strong&gt;founder de side-project o micro-SaaS&lt;/strong&gt; al que le&lt;br&gt;
importa el uptime pero no quiere pagar tooling enterprise para 1-3&lt;br&gt;
endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué construí esto en Fitz (mi propio lenguaje)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;Fitz&lt;/a&gt; es un lenguaje que vengo&lt;br&gt;
desarrollando hace meses con HTTP/async/ORM como ciudadanos de primera&lt;br&gt;
clase del core del lenguaje. fitzwatch es la prueba de fuego: ¿puedo&lt;br&gt;
construir un producto real, complejo, dockerizable, deployable a prod,&lt;br&gt;
usando solo Fitz?&lt;/p&gt;

&lt;p&gt;Spoiler: sí. ~22 módulos &lt;code&gt;.fitz&lt;/code&gt;, scheduler nativo con &lt;code&gt;@cron&lt;/code&gt;, ORM&lt;br&gt;
declarativo sobre Postgres, OpenAPI auto, WebSockets tipados, paridad&lt;br&gt;
bit-a-bit &lt;code&gt;fitz run&lt;/code&gt; ↔ &lt;code&gt;fitz build&lt;/code&gt; a binario nativo en Docker.&lt;/p&gt;

&lt;p&gt;(Eso es post separado — acá hablamos de la beta.)&lt;/p&gt;

&lt;h2&gt;
  
  
  La propuesta
&lt;/h2&gt;

&lt;p&gt;Si te sumás:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;6 meses&lt;/strong&gt; de acceso gratis ilimitado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soporte directo&lt;/strong&gt; por email cuando lo necesites.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Te pido a cambio:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setup de &lt;strong&gt;1 monitor real&lt;/strong&gt; (~5min).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call de 20min&lt;/strong&gt; de feedback al día 7-10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 preguntas por email&lt;/strong&gt; al día 14 (~5min).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si te copa el producto, opcional: permiso para usarte como testimonial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo sumarte
&lt;/h2&gt;

&lt;p&gt;Dejame un comentario abajo, mandame DM acá en dev.to. Te respondo en el día.&lt;/p&gt;

&lt;p&gt;Stack del producto si te da curiosidad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backend: Fitz (compilado a binario Rust nativo via codegen).&lt;/li&gt;
&lt;li&gt;DB: Postgres 16 + ORM declarativo de Fitz.&lt;/li&gt;
&lt;li&gt;Frontend público: vanilla JS + Tailwind.&lt;/li&gt;
&lt;li&gt;Frontend admin: Vue 3 + Vuetify + Chart.js (CDN, sin build step).&lt;/li&gt;
&lt;li&gt;Deploy: Docker + nginx en VPS DigitalOcean.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gracias por leer 🙏&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>startup</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Auth con JWT, RBAC y token blacklist sin pegar 5 librerías: Fitz vs FastAPI + python-jose + passlib + Redis + RBAC casero</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Fri, 26 Jun 2026 10:19:35 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/auth-con-jwt-rbac-y-token-blacklist-sin-pegar-5-librerias-fitz-vs-fastapi-python-jose-passlib-3e65</link>
      <guid>https://dev.to/martin_palopoli/auth-con-jwt-rbac-y-token-blacklist-sin-pegar-5-librerias-fitz-vs-fastapi-python-jose-passlib-3e65</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;El flow completo de auth — login, hashing Argon2id, JWT firma y verificación, RBAC custom apilable, logout con blacklist persistente — lado a lado en FastAPI y en Fitz. Cero librerías nuevas, todo built-in al lenguaje.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  La auth de verdad necesita más cosas de las que uno cree
&lt;/h2&gt;

&lt;p&gt;Empezás con "necesito login con JWT". Después aparece:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hashing de passwords&lt;/strong&gt; con un algoritmo bueno (Argon2id, no bcrypt para proyectos nuevos).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC&lt;/strong&gt; porque no alcanza con "logueado/no logueado" — hay roles, hay endpoints de admin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logout&lt;/strong&gt; que de verdad invalide el token (no solo "olvidate de él del lado del cliente").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh tokens&lt;/strong&gt; para no obligar al usuario a re-loguearse cada hora.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup&lt;/strong&gt; de la blacklist para no llenar Redis con tokens vencidos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;En FastAPI, cada una de esas cosas es una librería distinta más código pegamento. En Fitz, son parte del lenguaje.&lt;/p&gt;

&lt;h2&gt;
  
  
  El stack típico de Python
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;python-jose[cryptography] passlib[argon2] argon2-cffi redis python-multipart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jose&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;passlib.context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CryptContext&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.security&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OAuth2PasswordBearer&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis.asyncio&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;SECRET_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;ALGORITHM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;pwd_context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CryptContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;argon2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;deprecated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;oauth2_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OAuth2PasswordBearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redis_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REDIS_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hash_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;to_encode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&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="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;expires_delta&lt;/span&gt;
    &lt;span class="n"&gt;to_encode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jti&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_encode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_blacklisted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blacklist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;blacklist_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blacklist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oauth2_scheme&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&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="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jti&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;is_blacklisted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;checker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbidden&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;checker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;endpoints.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_user_by_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;verify_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password_hash&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_access_token&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;me&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;admin_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&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="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_all_users&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/articles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ArticleIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;editor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publisher&lt;/span&gt;&lt;span class="sh"&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="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/auth/logout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_and_jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_user_and_jti&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_and_jti&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;blacklist_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ahí va. Cinco librerías, ~100 líneas de boilerplate antes de escribir lógica de negocio, una conexión Redis adicional, y la responsabilidad de mantenerlo sincronizado.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo mismo en Fitz
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type User { id: Int, email: Str, name: Str, role: Str, password_hash: Str }
type Credentials { email: Str, password: Str }
type LoginResponse { token: Str }
type Article { id: Int, title: Str, body: Str, user_id: Int }
type ArticleIn { title: Str, body: Str }

let SECRET = secret("JWT_SECRET")

@auth_provider
async fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;, db: DbConn) -&amp;gt; Result&amp;lt;User&amp;gt; {
    let raw = match headers.get("authorization") {
        Ok(v) =&amp;gt; v,
        Err(_) =&amp;gt; return Err("falta header Authorization"),
    }
    let parts = raw.split(" ")
    if (parts.len() != 2 or parts[0] != "Bearer") {
        return Err("se esperaba 'Bearer &amp;lt;token&amp;gt;'")
    }
    let claims = jwt.decode(parts[1], SECRET.expose())?
    let jti = match claims.get("jti") {
        Ok(v) =&amp;gt; v,
        Err(_) =&amp;gt; return Err("token sin jti"),
    }
    if (auth.is_blacklisted(db, jti).await?) {
        return Err("token revocado")
    }
    return User.where(fn(u) =&amp;gt; u.email == claims["sub"]).first(db).await
}

@post("/login")
async fn login(db: DbConn, creds: Credentials) -&amp;gt; LoginResponse {
    let user: User = match User.where(fn(u) =&amp;gt; u.email == creds.email).first(db).await {
        Ok(u) =&amp;gt; u,
        Err(_) =&amp;gt; return 401 { "error": "credenciales inválidas" },
    }
    if (not hash.verify(creds.password, user.password_hash)) {
        return 401 { "error": "credenciales inválidas" }
    }
    let exp = DateTime.now().timestamp() + 86400  // 24 h de validez
    // El payload del JWT es `Map&amp;lt;Str, Str&amp;gt;` en `fitz build` MVP — los
    // numéricos se serializan a string. En `fitz run` la heterogeneidad
    // pasa, pero usamos el shape estricto para mantener paridad bit-a-bit.
    let claims = {
        "sub": user.email,
        "role": user.role,
        "jti": Uuid.v4().to_str(),
        "exp": "{exp}",
    }
    return LoginResponse { token: jwt.encode(claims, SECRET.expose()) }
}

@authenticated @get("/me")
fn me(user: User) -&amp;gt; User =&amp;gt; user

@admin @get("/admin/users")
async fn admin_list(db: DbConn, user: User) -&amp;gt; List&amp;lt;User&amp;gt; {
    return User.all(db).await
}

@requires("editor") @requires("publisher")
@post("/articles")
async fn create_article(db: DbConn, body: ArticleIn, user: User) -&amp;gt; Article {
    return Article.insert(db, Article { id: 0, title: body.title, body: body.body, user_id: user.id }).await?
}

@authenticated @post("/auth/logout")
async fn logout(db: DbConn, user: User, headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Map&amp;lt;Str, Bool&amp;gt; {
    // Re-decodificamos el token sólo para sacar jti del claims.
    let raw = headers["authorization"]
    let token = raw.split(" ")[1]
    let claims = jwt.decode(token, SECRET.expose())?
    let jti = claims.get("jti")?
    // TTL fresca de 24h. Cuando el JWT expira, `jwt.decode` ya lo rechaza
    // por expirado; la blacklist solo previene reuse del jti antes de exp.
    // No leemos exp del payload (vendría como Str y `auth.blacklist` exige Int).
    let ttl = DateTime.now().timestamp() + 86400
    auth.blacklist(db, jti, ttl).await?
    return { "ok": true }
}

@cron("0 0 3 * * *")
async fn cleanup_blacklist(db: DbConn) {
    auth.cleanup_expired(db).await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cero &lt;code&gt;pip install&lt;/code&gt;. Cero archivo &lt;code&gt;auth.py&lt;/code&gt; aparte. El compilador valida cada decorador estáticamente.&lt;/p&gt;

&lt;h2&gt;
  
  
  La tabla cruda
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pieza&lt;/th&gt;
&lt;th&gt;Python (FastAPI + 5 libs)&lt;/th&gt;
&lt;th&gt;Fitz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hash de passwords&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;passlib[argon2]&lt;/code&gt; + &lt;code&gt;CryptContext(schemes=["argon2"])&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hash.password(...)&lt;/code&gt; built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verificar password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pwd_context.verify(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hash.verify(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firmar JWT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jose.jwt.encode(..., SECRET, algorithm="HS256")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jwt.encode(claims, SECRET)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decodificar JWT&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jose.jwt.decode(...)&lt;/code&gt; + try/except&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jwt.decode(token, SECRET)?&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth scheme&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OAuth2PasswordBearer&lt;/code&gt; + &lt;code&gt;Depends(...)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@auth_provider fn check_token(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint protegido&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Depends(get_current_user)&lt;/code&gt; en cada uno&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@authenticated&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin only&lt;/td&gt;
&lt;td&gt;RBAC casero con &lt;code&gt;require_role("admin")&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@admin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RBAC custom&lt;/td&gt;
&lt;td&gt;Helper &lt;code&gt;require_role(*roles)&lt;/code&gt; + Depends&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@requires("editor")&lt;/code&gt; apilable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token blacklist&lt;/td&gt;
&lt;td&gt;Redis + lógica manual + TTL calculado a mano&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth.blacklist(db, jti, exp)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleanup vencidos&lt;/td&gt;
&lt;td&gt;Redis SET TTL (auto) o tú manual si DB&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;auth.cleanup_expired(db)&lt;/code&gt; + &lt;code&gt;@cron&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validación estática&lt;/td&gt;
&lt;td&gt;Ninguna — error en runtime cuando llega req&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@authenticated&lt;/code&gt; sin &lt;code&gt;@auth_provider&lt;/code&gt; → error de compilación&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema OpenAPI con &lt;code&gt;bearerAuth&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tenés que declararlo en &lt;code&gt;app = FastAPI(...)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Automático cuando hay &lt;code&gt;@auth_provider&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Por partes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hash de passwords
&lt;/h3&gt;

&lt;p&gt;Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;passlib.context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CryptContext&lt;/span&gt;
&lt;span class="n"&gt;pwd_context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CryptContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;argon2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;deprecated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;hashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let hashed = hash.password("password123")
let ok = hash.verify("password123", hashed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Argon2id por defecto (recomendación OWASP). Sin elegir esquemas, sin configurar &lt;code&gt;deprecated="auto"&lt;/code&gt;. Lo que querés con buenos defaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  JWT
&lt;/h3&gt;

&lt;p&gt;Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jose&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&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="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&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="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid token: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let exp = DateTime.now().timestamp() + 86400
let token = jwt.encode({ "sub": "ada", "exp": "{exp}" }, SECRET)

match jwt.decode(token, SECRET) {
    Ok(claims) =&amp;gt; process(claims),
    Err(e) =&amp;gt; return 401 { "error": "{e}" },
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HS256 por default. HS384/HS512 disponibles con kwarg. Excepción → &lt;code&gt;Result::Err&lt;/code&gt; automático, manejado con &lt;code&gt;?&lt;/code&gt; o &lt;code&gt;match&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  RBAC custom apilable
&lt;/h3&gt;

&lt;p&gt;Acá es donde Fitz claramente diferencia. En FastAPI, RBAC más allá de "admin sí / no" significa escribir un helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;checker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbidden&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;checker&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/articles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ArticleIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;editor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publisher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@requires("editor") @requires("publisher")
@post("/articles")
async fn create_article(db: DbConn, body: ArticleIn, user: User) -&amp;gt; Article {
    ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multi-decorator es OR (el user pertenece a uno &lt;strong&gt;u otro&lt;/strong&gt; role). El checker valida estáticamente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Que &lt;code&gt;User&lt;/code&gt; tenga campo &lt;code&gt;role: Str&lt;/code&gt; no nullable.&lt;/li&gt;
&lt;li&gt;Que no haya duplicados (&lt;code&gt;@requires("editor") @requires("editor")&lt;/code&gt; → error compile-time).&lt;/li&gt;
&lt;li&gt;Que el handler tenga un &lt;code&gt;@auth_provider&lt;/code&gt; activo en el archivo.&lt;/li&gt;
&lt;li&gt;Que el último param del handler que matchee con el type del provider sea el inyectado.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Errores que en Python aparecen en runtime cuando un user le pega al endpoint, en Fitz aparecen en &lt;code&gt;fitz check&lt;/code&gt; antes de commitear.&lt;/p&gt;

&lt;h3&gt;
  
  
  Token blacklist persistente
&lt;/h3&gt;

&lt;p&gt;Esta es la pieza que muchos proyectos en FastAPI nunca terminan de hacer bien — porque significa sumar Redis aunque tu app no la necesite para nada más, escribir el client async, calcular TTLs a mano, decidir si la falla de Redis tira el request o lo deja pasar...&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;blacklist_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blacklist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_blacklisted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blacklist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus mantener Redis vivo, montar el client, manejar reconexiones.&lt;/p&gt;

&lt;p&gt;En Fitz, usás el Postgres que ya tenés:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@authenticated
@post("/auth/logout")
async fn logout(db: DbConn, user: User) -&amp;gt; Map&amp;lt;Str, Bool&amp;gt; {
    auth.blacklist(db, user.jti, user.exp).await?
    return { "ok": true }
}

@cron("0 0 3 * * *")  // 3 AM diario
async fn cleanup_blacklist(db: DbConn) {
    auth.cleanup_expired(db).await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tabla &lt;code&gt;fitz_token_blacklist(jti TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)&lt;/code&gt; se auto-crea con &lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt; al primer call. Auto-filtro &lt;code&gt;expires_at &amp;gt; now()&lt;/code&gt; cuando checkea (tokens vencidos no necesitan seguir bloqueando — &lt;code&gt;jwt.decode&lt;/code&gt; los rechaza primero por &lt;code&gt;exp&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;El patrón &lt;code&gt;/auth/logout&lt;/code&gt; y &lt;code&gt;/auth/refresh&lt;/code&gt; no se auto-monta (intencional — el flow exacto varía por proyecto), pero los tres builtins (&lt;code&gt;auth.blacklist&lt;/code&gt;/&lt;code&gt;auth.is_blacklisted&lt;/code&gt;/&lt;code&gt;auth.cleanup_expired&lt;/code&gt;) te lo dan hecho en ~10 líneas de tu código.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenAPI con &lt;code&gt;bearerAuth&lt;/code&gt; automático
&lt;/h3&gt;

&lt;p&gt;En FastAPI tenés que declararlo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;swagger_ui_init_oauth&lt;/span&gt;&lt;span class="o"&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;# y la security scheme en cada endpoint manualmente o con dependencies
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Fitz, cuando hay &lt;code&gt;@auth_provider&lt;/code&gt; en el programa, el schema OpenAPI se emite con:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"components"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bearerAuth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"scheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"bearerFormat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"paths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/me"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"get"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"security"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"bearerAuth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"responses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"200"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"401"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La UI de Scalar en &lt;code&gt;/docs&lt;/code&gt; te muestra el botón "Authorize" funcional. Auto-mounted. Sin configurar.&lt;/p&gt;

&lt;h2&gt;
  
  
  El checker hace gran parte del trabajo
&lt;/h2&gt;

&lt;p&gt;Antes de levantar el server, &lt;code&gt;fitz check&lt;/code&gt; valida:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@authenticated&lt;/code&gt; sin &lt;code&gt;@auth_provider&lt;/code&gt;&lt;/strong&gt; → "se usa &lt;a class="mentioned-user" href="https://dev.to/authenticated"&gt;@authenticated&lt;/a&gt; pero no hay @auth_provider declarado".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@admin&lt;/code&gt; sobre handler cuyo User no tiene &lt;code&gt;role: Str&lt;/code&gt; no nullable&lt;/strong&gt; → "para usar @admin el type User debe tener campo &lt;code&gt;role: Str&lt;/code&gt;".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@requires("X")&lt;/code&gt; con typo&lt;/strong&gt; → no chequea valores (el role es un Str arbitrario), pero detecta apilados duplicados.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT decode return type&lt;/strong&gt; → &lt;code&gt;Result&amp;lt;Map&amp;lt;Str, Str&amp;gt;&amp;gt;&lt;/code&gt;, exige manejo de Err.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El param inyectado por &lt;code&gt;@auth_provider&lt;/code&gt;&lt;/strong&gt; → su tipo debe matchear el del User retornado por el provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Errores que en FastAPI/Flask típicamente se descubren cuando el request llega a producción, en Fitz aparecen en CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisiones de diseño que vale la pena entender
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;auth.blacklist&lt;/code&gt; exige DbConn explícita
&lt;/h3&gt;

&lt;p&gt;En lugar de un singleton global, los 3 builtins (&lt;code&gt;auth.blacklist&lt;/code&gt;/&lt;code&gt;is_blacklisted&lt;/code&gt;/&lt;code&gt;cleanup_expired&lt;/code&gt;) toman &lt;code&gt;DbConn&lt;/code&gt; como primer arg. Razón: hace explícito el costo en cada call site, y testeable contra una DB de test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mensaje de 403 enriquecido para &lt;code&gt;@requires&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Cuando el role no matchea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"forbidden"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"viewer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required_roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"publisher"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Habilita debugging serio y observability (los logs estructurados con &lt;code&gt;log.info("auth.denied", { user_id, role, required })&lt;/code&gt; te dicen exactamente qué falló).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;@requires&lt;/code&gt; implica auth
&lt;/h3&gt;

&lt;p&gt;No necesitás apilar &lt;code&gt;@authenticated @requires("editor")&lt;/code&gt; — el &lt;code&gt;@requires&lt;/code&gt; ya corre el provider. Apilarlo es no-op (y el checker lo flagea).&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que Fitz NO te da (todavía)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OAuth2 social login&lt;/strong&gt; (Google/GitHub/etc.) — el flow OAuth contra el provider lo hacés a mano contra los endpoints conocidos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-role&lt;/strong&gt; (&lt;code&gt;user.roles: List&amp;lt;Str&amp;gt;&lt;/code&gt;) — hoy es single role. Roles compuestos: deuda residual de 9.w.1.iter2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asymmetric JWT&lt;/strong&gt; (RS256/ES256 con PEM y rotación) — solo HS256/384/512.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role hierarchy&lt;/strong&gt; (admin &amp;gt; editor &amp;gt; viewer) — no se modela. Workaround: si admin debe poder hacer lo que editor, &lt;code&gt;@requires("admin") @requires("editor")&lt;/code&gt; (apilado).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session cookie-based&lt;/strong&gt; alternativo a JWT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/auth/refresh&lt;/code&gt; y &lt;code&gt;/auth/logout&lt;/code&gt; auto-mounted&lt;/strong&gt; — el patrón canónico (~10 LoC con los builtins) está en el cap 28 de la guía pero queda manual. Auto-mount con &lt;code&gt;@server(auto_auth_endpoints=true)&lt;/code&gt; es deuda visible.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cierre
&lt;/h2&gt;

&lt;p&gt;Lo que más me cansa de hacer auth en Python no es escribir el primer endpoint — es mantener consistencia entre tres lugares: el &lt;code&gt;Depends(...)&lt;/code&gt; de FastAPI, la lib de JWT, y el RBAC casero. Cuando agregás &lt;code&gt;@requires("editor")&lt;/code&gt; y te olvidaste de actualizar el helper del RBAC, no te enterás hasta que un user te reporta un bug.&lt;/p&gt;

&lt;p&gt;En Fitz el checker estático cierra ese loop. El &lt;code&gt;@auth_provider&lt;/code&gt; es la fuente de verdad del tipo &lt;code&gt;User&lt;/code&gt;. &lt;code&gt;@authenticated&lt;/code&gt;/&lt;code&gt;@admin&lt;/code&gt;/&lt;code&gt;@requires&lt;/code&gt; consumen ese tipo. La OpenAPI lo refleja. La blacklist usa la misma DB. Cero deps externas para auth.&lt;/p&gt;

&lt;p&gt;Si tu proyecto está en uno de los casos del MVP — JWT, roles simples, blacklist en Postgres — Fitz te ahorra el día de pegar librerías. Si necesitás OAuth social y multi-role, todavía es Python (o tomalo como invitación a abrir un issue).&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Próximo post de la serie&lt;/strong&gt;: &lt;strong&gt;"Tracing distribuido, métricas Prometheus y logs estructurados con dos decoradores: &lt;code&gt;@trace&lt;/code&gt;/&lt;code&gt;@metric&lt;/code&gt; en Fitz vs el setup de OpenTelemetry en FastAPI"&lt;/strong&gt; — observability lado a lado.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Capítulo 28 de la guía&lt;/strong&gt; (Auth nativa): &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;docs/guide.md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>JWT auth, RBAC, and token blacklist without gluing 5 libraries: Fitz vs FastAPI + python-jose + passlib + Redis + home-grown RBAC</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Fri, 26 Jun 2026 10:19:17 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/jwt-auth-rbac-and-token-blacklist-without-gluing-5-libraries-fitz-vs-fastapi-python-jose--32f9</link>
      <guid>https://dev.to/martin_palopoli/jwt-auth-rbac-and-token-blacklist-without-gluing-5-libraries-fitz-vs-fastapi-python-jose--32f9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The full auth flow — login, Argon2id hashing, JWT sign and verify, stackable custom RBAC, logout with persistent blacklist — side by side in FastAPI and in Fitz. Zero new libraries, everything built into the language.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Real auth needs more pieces than you'd think
&lt;/h2&gt;

&lt;p&gt;You start with "I need login with JWT". Then comes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Password hashing&lt;/strong&gt; with a good algorithm (Argon2id, not bcrypt for new projects).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC&lt;/strong&gt; because "logged in / not logged in" isn't enough — there are roles, there are admin endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logout&lt;/strong&gt; that actually invalidates the token (not just "forget about it on the client side").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh tokens&lt;/strong&gt; so users don't have to re-log every hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup&lt;/strong&gt; of the blacklist so you don't fill Redis with expired tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In FastAPI, each of those is a separate library plus glue code. In Fitz, they're part of the language.&lt;/p&gt;

&lt;h2&gt;
  
  
  The typical Python stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;python-jose[cryptography] passlib[argon2] argon2-cffi redis python-multipart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jose&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;passlib.context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CryptContext&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.security&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OAuth2PasswordBearer&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis.asyncio&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;SECRET_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;ALGORITHM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;pwd_context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CryptContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;argon2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;deprecated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;oauth2_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OAuth2PasswordBearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redis_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REDIS_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hash_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;to_encode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&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="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;expires_delta&lt;/span&gt;
    &lt;span class="n"&gt;to_encode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jti&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_encode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_blacklisted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blacklist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;blacklist_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blacklist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oauth2_scheme&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&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="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jti&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;is_blacklisted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;checker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbidden&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;checker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;endpoints.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_user_by_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;verify_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password_hash&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_access_token&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;me&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;admin_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&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="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_all_users&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/articles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ArticleIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;editor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publisher&lt;/span&gt;&lt;span class="sh"&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="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/auth/logout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_and_jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_user_and_jti&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_and_jti&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;blacklist_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. Five libraries, ~100 lines of boilerplate before you write any business logic, an extra Redis connection, and the responsibility to keep it in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing in Fitz
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type User { id: Int, email: Str, name: Str, role: Str, password_hash: Str }
type Credentials { email: Str, password: Str }
type LoginResponse { token: Str }
type Article { id: Int, title: Str, body: Str, user_id: Int }
type ArticleIn { title: Str, body: Str }

let SECRET = secret("JWT_SECRET")

@auth_provider
async fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;, db: DbConn) -&amp;gt; Result&amp;lt;User&amp;gt; {
    let raw = match headers.get("authorization") {
        Ok(v) =&amp;gt; v,
        Err(_) =&amp;gt; return Err("missing Authorization header"),
    }
    let parts = raw.split(" ")
    if (parts.len() != 2 or parts[0] != "Bearer") {
        return Err("expected 'Bearer &amp;lt;token&amp;gt;'")
    }
    let claims = jwt.decode(parts[1], SECRET.expose())?
    let jti = match claims.get("jti") {
        Ok(v) =&amp;gt; v,
        Err(_) =&amp;gt; return Err("token missing jti"),
    }
    if (auth.is_blacklisted(db, jti).await?) {
        return Err("token revoked")
    }
    return User.where(fn(u) =&amp;gt; u.email == claims["sub"]).first(db).await
}

@post("/login")
async fn login(db: DbConn, creds: Credentials) -&amp;gt; LoginResponse {
    let user: User = match User.where(fn(u) =&amp;gt; u.email == creds.email).first(db).await {
        Ok(u) =&amp;gt; u,
        Err(_) =&amp;gt; return 401 { "error": "invalid credentials" },
    }
    if (not hash.verify(creds.password, user.password_hash)) {
        return 401 { "error": "invalid credentials" }
    }
    let exp = DateTime.now().timestamp() + 86400  // 24h validity
    // The JWT payload is `Map&amp;lt;Str, Str&amp;gt;` in the `fitz build` MVP — numerics
    // serialize to string. In `fitz run` heterogeneity passes, but we use
    // the strict shape to preserve bit-for-bit parity.
    let claims = {
        "sub": user.email,
        "role": user.role,
        "jti": Uuid.v4().to_str(),
        "exp": "{exp}",
    }
    return LoginResponse { token: jwt.encode(claims, SECRET.expose()) }
}

@authenticated @get("/me")
fn me(user: User) -&amp;gt; User =&amp;gt; user

@admin @get("/admin/users")
async fn admin_list(db: DbConn, user: User) -&amp;gt; List&amp;lt;User&amp;gt; {
    return User.all(db).await
}

@requires("editor") @requires("publisher")
@post("/articles")
async fn create_article(db: DbConn, body: ArticleIn, user: User) -&amp;gt; Article {
    return Article.insert(db, Article { id: 0, title: body.title, body: body.body, user_id: user.id }).await?
}

@authenticated @post("/auth/logout")
async fn logout(db: DbConn, user: User, headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Map&amp;lt;Str, Bool&amp;gt; {
    // We re-decode the token just to pull jti from the claims.
    let raw = headers["authorization"]
    let token = raw.split(" ")[1]
    let claims = jwt.decode(token, SECRET.expose())?
    let jti = claims.get("jti")?
    // Fresh 24h TTL. When the JWT expires, `jwt.decode` rejects it as
    // expired; the blacklist only prevents reuse of the jti before exp.
    // We don't read exp from the payload (it would come back as Str and
    // `auth.blacklist` requires Int).
    let ttl = DateTime.now().timestamp() + 86400
    auth.blacklist(db, jti, ttl).await?
    return { "ok": true }
}

@cron("0 0 3 * * *")
async fn cleanup_blacklist(db: DbConn) {
    auth.cleanup_expired(db).await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero &lt;code&gt;pip install&lt;/code&gt;. Zero separate &lt;code&gt;auth.py&lt;/code&gt; file. The compiler validates every decorator statically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Python (FastAPI + 5 libs)&lt;/th&gt;
&lt;th&gt;Fitz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Password hashing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;passlib[argon2]&lt;/code&gt; + &lt;code&gt;CryptContext(schemes=["argon2"])&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hash.password(...)&lt;/code&gt; built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pwd_context.verify(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hash.verify(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sign JWT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jose.jwt.encode(..., SECRET, algorithm="HS256")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jwt.encode(claims, SECRET)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode JWT&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jose.jwt.decode(...)&lt;/code&gt; + try/except&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jwt.decode(token, SECRET)?&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth scheme&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OAuth2PasswordBearer&lt;/code&gt; + &lt;code&gt;Depends(...)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@auth_provider fn check_token(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protected endpoint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Depends(get_current_user)&lt;/code&gt; on each one&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@authenticated&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin only&lt;/td&gt;
&lt;td&gt;Home-grown RBAC with &lt;code&gt;require_role("admin")&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@admin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom RBAC&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;require_role(*roles)&lt;/code&gt; helper + Depends&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@requires("editor")&lt;/code&gt; stackable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token blacklist&lt;/td&gt;
&lt;td&gt;Redis + manual logic + manually computed TTL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth.blacklist(db, jti, exp)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expired cleanup&lt;/td&gt;
&lt;td&gt;Redis SET TTL (auto) or manual if DB&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;auth.cleanup_expired(db)&lt;/code&gt; + &lt;code&gt;@cron&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static validation&lt;/td&gt;
&lt;td&gt;None — runtime error when a request hits&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@authenticated&lt;/code&gt; without &lt;code&gt;@auth_provider&lt;/code&gt; → compile error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAPI &lt;code&gt;bearerAuth&lt;/code&gt; schema&lt;/td&gt;
&lt;td&gt;Declare in &lt;code&gt;app = FastAPI(...)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Automatic when &lt;code&gt;@auth_provider&lt;/code&gt; is present&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Piece by piece
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Password hashing
&lt;/h3&gt;

&lt;p&gt;Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;passlib.context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CryptContext&lt;/span&gt;
&lt;span class="n"&gt;pwd_context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CryptContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;argon2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;deprecated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;hashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let hashed = hash.password("password123")
let ok = hash.verify("password123", hashed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Argon2id by default (OWASP recommendation). No picking schemes, no configuring &lt;code&gt;deprecated="auto"&lt;/code&gt;. What you want with good defaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  JWT
&lt;/h3&gt;

&lt;p&gt;Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jose&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&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="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&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="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid token: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let exp = DateTime.now().timestamp() + 86400
let token = jwt.encode({ "sub": "ada", "exp": "{exp}" }, SECRET)

match jwt.decode(token, SECRET) {
    Ok(claims) =&amp;gt; process(claims),
    Err(e) =&amp;gt; return 401 { "error": "{e}" },
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HS256 by default. HS384/HS512 available via kwarg. Exception → &lt;code&gt;Result::Err&lt;/code&gt; automatic, handled with &lt;code&gt;?&lt;/code&gt; or &lt;code&gt;match&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stackable custom RBAC
&lt;/h3&gt;

&lt;p&gt;This is where Fitz clearly stands apart. In FastAPI, RBAC beyond "admin yes / no" means writing a helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;checker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbidden&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;checker&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/articles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ArticleIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;require_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;editor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publisher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@requires("editor") @requires("publisher")
@post("/articles")
async fn create_article(db: DbConn, body: ArticleIn, user: User) -&amp;gt; Article {
    ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multi-decorator is OR (the user belongs to one &lt;strong&gt;or&lt;/strong&gt; the other role). The checker validates statically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That &lt;code&gt;User&lt;/code&gt; has a non-nullable &lt;code&gt;role: Str&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;That there are no duplicates (&lt;code&gt;@requires("editor") @requires("editor")&lt;/code&gt; → compile-time error).&lt;/li&gt;
&lt;li&gt;That the handler has an active &lt;code&gt;@auth_provider&lt;/code&gt; in the file.&lt;/li&gt;
&lt;li&gt;That the handler's last param matching the provider's return type is the injected one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Errors that in Python appear at runtime when a user hits the endpoint, in Fitz appear in &lt;code&gt;fitz check&lt;/code&gt; before you commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Persistent token blacklist
&lt;/h3&gt;

&lt;p&gt;This is the piece many FastAPI projects never finish properly — because it means adding Redis even though your app doesn't need it for anything else, writing the async client, computing TTLs by hand, deciding whether a Redis failure should fail the request or let it through...&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;blacklist_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blacklist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_blacklisted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blacklist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus keeping Redis alive, mounting the client, handling reconnects.&lt;/p&gt;

&lt;p&gt;In Fitz, you use the Postgres you already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@authenticated
@post("/auth/logout")
async fn logout(db: DbConn, user: User) -&amp;gt; Map&amp;lt;Str, Bool&amp;gt; {
    auth.blacklist(db, user.jti, user.exp).await?
    return { "ok": true }
}

@cron("0 0 3 * * *")  // 3 AM daily
async fn cleanup_blacklist(db: DbConn) {
    auth.cleanup_expired(db).await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fitz_token_blacklist(jti TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)&lt;/code&gt; table auto-creates with &lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt; on the first call. Auto-filters &lt;code&gt;expires_at &amp;gt; now()&lt;/code&gt; on check (expired tokens don't need to keep blocking — &lt;code&gt;jwt.decode&lt;/code&gt; rejects them first via &lt;code&gt;exp&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/auth/logout&lt;/code&gt; and &lt;code&gt;/auth/refresh&lt;/code&gt; patterns are not auto-mounted (intentional — the exact flow varies by project), but the three builtins (&lt;code&gt;auth.blacklist&lt;/code&gt;/&lt;code&gt;auth.is_blacklisted&lt;/code&gt;/&lt;code&gt;auth.cleanup_expired&lt;/code&gt;) give you the work in ~10 lines of your code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automatic &lt;code&gt;bearerAuth&lt;/code&gt; in OpenAPI
&lt;/h3&gt;

&lt;p&gt;In FastAPI you have to declare it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;swagger_ui_init_oauth&lt;/span&gt;&lt;span class="o"&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;# and the security scheme on each endpoint manually or via dependencies
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Fitz, when the program has &lt;code&gt;@auth_provider&lt;/code&gt;, the OpenAPI schema emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"components"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bearerAuth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"scheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"bearerFormat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"paths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/me"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"get"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"security"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"bearerAuth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"responses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"200"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"401"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Scalar UI at &lt;code&gt;/docs&lt;/code&gt; shows you a working "Authorize" button. Auto-mounted. No config.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checker does much of the work
&lt;/h2&gt;

&lt;p&gt;Before bringing up the server, &lt;code&gt;fitz check&lt;/code&gt; validates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@authenticated&lt;/code&gt; without &lt;code&gt;@auth_provider&lt;/code&gt;&lt;/strong&gt; → "uses &lt;a class="mentioned-user" href="https://dev.to/authenticated"&gt;@authenticated&lt;/a&gt; but no @auth_provider declared".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@admin&lt;/code&gt; over a handler whose User has no &lt;code&gt;role: Str&lt;/code&gt; non-nullable&lt;/strong&gt; → "to use @admin the User type must have a &lt;code&gt;role: Str&lt;/code&gt; field".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@requires("X")&lt;/code&gt; with a typo&lt;/strong&gt; → doesn't check values (role is an arbitrary Str), but detects duplicate stacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT decode return type&lt;/strong&gt; → &lt;code&gt;Result&amp;lt;Map&amp;lt;Str, Str&amp;gt;&amp;gt;&lt;/code&gt;, requires handling Err.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The param injected by &lt;code&gt;@auth_provider&lt;/code&gt;&lt;/strong&gt; → its type must match the User returned by the provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Errors that in FastAPI/Flask typically get discovered when the request lands in production, in Fitz show up in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions worth understanding
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;auth.blacklist&lt;/code&gt; requires an explicit DbConn
&lt;/h3&gt;

&lt;p&gt;Instead of a global singleton, the 3 builtins (&lt;code&gt;auth.blacklist&lt;/code&gt;/&lt;code&gt;is_blacklisted&lt;/code&gt;/&lt;code&gt;cleanup_expired&lt;/code&gt;) take &lt;code&gt;DbConn&lt;/code&gt; as the first arg. Reason: it makes the cost explicit at each call site, and testable against a test DB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enriched 403 message for &lt;code&gt;@requires&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When the role doesn't match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"forbidden"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"viewer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required_roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"publisher"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enables serious debugging and observability (structured logs with &lt;code&gt;log.info("auth.denied", { user_id, role, required })&lt;/code&gt; tell you exactly what failed).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;@requires&lt;/code&gt; implies auth
&lt;/h3&gt;

&lt;p&gt;You don't need to stack &lt;code&gt;@authenticated @requires("editor")&lt;/code&gt; — &lt;code&gt;@requires&lt;/code&gt; already runs the provider. Stacking it is a no-op (and the checker flags it).&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fitz does NOT give you (yet)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OAuth2 social login&lt;/strong&gt; (Google/GitHub/etc.) — the OAuth flow against the provider you do by hand against the known endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-role&lt;/strong&gt; (&lt;code&gt;user.roles: List&amp;lt;Str&amp;gt;&lt;/code&gt;) — today it's single role. Composite roles: residual debt of 9.w.1.iter2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asymmetric JWT&lt;/strong&gt; (RS256/ES256 with PEM and rotation) — only HS256/384/512.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role hierarchy&lt;/strong&gt; (admin &amp;gt; editor &amp;gt; viewer) — not modeled. Workaround: if admin should also be able to do what editor does, &lt;code&gt;@requires("admin") @requires("editor")&lt;/code&gt; (stacked).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie-based sessions&lt;/strong&gt; as an alternative to JWT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/auth/refresh&lt;/code&gt; and &lt;code&gt;/auth/logout&lt;/code&gt; auto-mounted&lt;/strong&gt; — the canonical pattern (~10 LoC with the builtins) is in chapter 28 of the guide but stays manual. Auto-mount with &lt;code&gt;@server(auto_auth_endpoints=true)&lt;/code&gt; is visible debt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;What tires me most about doing auth in Python isn't writing the first endpoint — it's keeping consistency across three places: FastAPI's &lt;code&gt;Depends(...)&lt;/code&gt;, the JWT library, and the home-grown RBAC. When you add &lt;code&gt;@requires("editor")&lt;/code&gt; and you forgot to update the RBAC helper, you don't find out until a user reports a bug.&lt;/p&gt;

&lt;p&gt;In Fitz the static checker closes that loop. The &lt;code&gt;@auth_provider&lt;/code&gt; is the source of truth for the &lt;code&gt;User&lt;/code&gt; type. &lt;code&gt;@authenticated&lt;/code&gt;/&lt;code&gt;@admin&lt;/code&gt;/&lt;code&gt;@requires&lt;/code&gt; consume that type. OpenAPI reflects it. The blacklist uses the same DB. Zero external deps for auth.&lt;/p&gt;

&lt;p&gt;If your project is in one of the MVP cases — JWT, simple roles, blacklist in Postgres — Fitz saves you the day of gluing libraries together. If you need OAuth social and multi-role, it's still Python (or take it as an invitation to open an issue).&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next post in the series&lt;/strong&gt;: &lt;strong&gt;"Distributed tracing, Prometheus metrics, and structured logs with two decorators: &lt;code&gt;@trace&lt;/code&gt;/&lt;code&gt;@metric&lt;/code&gt; in Fitz vs the OpenTelemetry setup in FastAPI"&lt;/strong&gt; — observability side by side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Chapter 28 of the guide&lt;/strong&gt; (Native auth): &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;docs/guide.md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Cron jobs sin Celery, sin Redis, sin Beat: cómo Fitz mete un scheduler distribuido adentro del lenguaje</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Mon, 22 Jun 2026 23:10:31 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/cron-jobs-sin-celery-sin-redis-sin-beat-como-fitz-mete-un-scheduler-distribuido-adentro-del-bj9</link>
      <guid>https://dev.to/martin_palopoli/cron-jobs-sin-celery-sin-redis-sin-beat-como-fitz-mete-un-scheduler-distribuido-adentro-del-bj9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Para correr una task cada 5 minutos en Python necesitás Celery + Redis + Celery Beat + worker process + Dockerfile dedicado. En Fitz es un decorador. Con retry, timezone, persistencia y catch-up adentro del lenguaje.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  La historia de siempre
&lt;/h2&gt;

&lt;p&gt;Tu cliente está contento con la API. Está corriendo. Entonces te pide tres cosas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"¿Podemos limpiar sesiones vencidas cada noche?"&lt;/li&gt;
&lt;li&gt;"¿Podés mandar el email de bienvenida en background así el signup no espera?"&lt;/li&gt;
&lt;li&gt;"El reporte diario tiene que correr a las 9 AM hora de Buenos Aires."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tres pedidos chicos. Si estás en Python, la respuesta honesta es: "ok, dame dos días para meter Celery."&lt;/p&gt;

&lt;h3&gt;
  
  
  El stack típico de Python
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;celery redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;celery_app.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;celery&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Celery&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;celery.schedules&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;crontab&lt;/span&gt;

&lt;span class="n"&gt;celery_app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Celery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://redis:6379/0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://redis:6379/1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;celery_app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beat_schedule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cleanup-sessions-nightly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.tasks.cleanup_old_sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schedule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;crontab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily-report&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.tasks.generate_daily_report&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schedule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;crontab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# 9am Buenos Aires = 12:00 UTC
&lt;/span&gt;        &lt;span class="c1"&gt;# ojo: si DST cambia, esto te llega tarde
&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;celery_app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UTC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tasks.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.celery_app&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;celery_app&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_session&lt;/span&gt;

&lt;span class="nd"&gt;@celery_app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autoretry_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="n"&gt;retry_backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cleanup_old_sessions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;get_session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM sessions WHERE expires_at &amp;lt; now()&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@celery_app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autoretry_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SMTPError&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="n"&gt;retry_backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_welcome_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;smtp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/signup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;send_welcome_email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# fire-and-forget vía broker
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uvicorn api:app --host 0.0.0.0&lt;/span&gt;
  &lt;span class="na"&gt;celery-worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;celery -A celery_app worker --loglevel=info&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;celery-beat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;celery -A celery_app beat --loglevel=info&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Un &lt;code&gt;supervisord.conf&lt;/code&gt; o systemd unit que mantenga viva la cosa cuando crashee.&lt;/li&gt;
&lt;li&gt;(Opcional) &lt;code&gt;flower&lt;/code&gt; para visibility — 4to proceso.&lt;/li&gt;
&lt;li&gt;Conversiones de timezone hechas a mano (Celery beat trabaja en UTC, vos tenés que calcular el offset).&lt;/li&gt;
&lt;li&gt;Cuando el worker crashea entre que pegó al broker y completó la task: ¿se reintenta? ¿queda colgada? Depende de cómo te configuraste el &lt;code&gt;acks_late&lt;/code&gt; y &lt;code&gt;task_reject_on_worker_lost&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cuatro procesos en producción. Tres librerías nuevas. Un broker. Convenciones nuevas. Un día de setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo mismo en Fitz
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 3 * * *", tz="UTC")
async fn cleanup_old_sessions(db: DbConn) {
    db.exec("DELETE FROM sessions WHERE expires_at &amp;lt; now()").await
}

@cron("0 9 * * *", tz="America/Argentina/Buenos_Aires")
async fn daily_report(db: DbConn) {
    // ...
}

@background
async fn send_welcome_email(email: Str) {
    // cosa cara
}

@post("/signup")
fn signup(creds: Credentials) -&amp;gt; User {
    let user = create_user(creds)
    spawn(send_welcome_email(user.email))  // fire-and-forget tipado
    return user
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso es todo. Un binario. Un proceso. Sin broker. Sin worker dedicado. Sin beat. El scheduler corre adentro del proceso de Fitz.&lt;/p&gt;

&lt;h2&gt;
  
  
  La tabla cruda
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Python (Celery + Redis + Beat)&lt;/th&gt;
&lt;th&gt;Fitz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup inicial&lt;/td&gt;
&lt;td&gt;4 archivos + 3 servicios + 1 broker&lt;/td&gt;
&lt;td&gt;1 decorador&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedule&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;crontab(hour=3, minute=0)&lt;/code&gt; en config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@cron("0 3 * * *")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timezone&lt;/td&gt;
&lt;td&gt;UTC + offset manual&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tz="America/Argentina/Buenos_Aires"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry/backoff&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;autoretry_for=(...)&lt;/code&gt; + &lt;code&gt;retry_backoff=True&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;retry={max=3, backoff="exponential", initial_secs=1, max_secs=30}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistencia de runs&lt;/td&gt;
&lt;td&gt;Redis result backend + visualización con Flower&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;store=db&lt;/code&gt;, tabla auto-creada en Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catch-up de runs perdidos&lt;/td&gt;
&lt;td&gt;No nativo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;catch_up=true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background fire-and-forget&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.delay(arg)&lt;/code&gt; con broker&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spawn(fn_call)&lt;/code&gt; directo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type checking de args&lt;/td&gt;
&lt;td&gt;Ninguno (todo serializado vía JSON)&lt;/td&gt;
&lt;td&gt;Static —&lt;code&gt;spawn&lt;/code&gt; exige &lt;code&gt;@background&lt;/code&gt; y refina a &lt;code&gt;Future&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Procesos en prod&lt;/td&gt;
&lt;td&gt;api + worker + beat + redis&lt;/td&gt;
&lt;td&gt;api&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Imagen Docker&lt;/td&gt;
&lt;td&gt;3 (api, worker, beat) + redis&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Persistencia opt-in con &lt;code&gt;store=db&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Cuando querés history de runs para auditoría:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 3 * * *", store=db, retry={max=3, backoff="exponential"})
async fn cleanup_old_sessions(db: DbConn) {
    db.exec("DELETE FROM sessions WHERE expires_at &amp;lt; now()").await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Al boot, Fitz crea (si no existe) &lt;code&gt;fitz_cron_jobs&lt;/code&gt; y &lt;code&gt;fitz_cron_runs&lt;/code&gt;. Cada attempt queda persistida con &lt;code&gt;started_at&lt;/code&gt;/&lt;code&gt;finished_at&lt;/code&gt;/&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;attempt&lt;/code&gt;/&lt;code&gt;error&lt;/code&gt;. Lo consultás con SQL plano:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fitz_cron_runs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sin instalar Flower. Sin webhook para Sentry. Tu DB que ya tenés.&lt;/p&gt;

&lt;h3&gt;
  
  
  Catch-up
&lt;/h3&gt;

&lt;p&gt;Si el binario estuvo caído entre las 3 AM y las 7 AM y el cron era a las 3:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Celery beat&lt;/strong&gt;: la oportunidad se pierde. La task no se vuelve a disparar hasta la próxima medianoche siguiente.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fitz con &lt;code&gt;catch_up=true&lt;/code&gt;&lt;/strong&gt;: al boot, calcula que hubo un run perdido entre &lt;code&gt;last_run_at&lt;/code&gt; y &lt;code&gt;now&lt;/code&gt;, ejecuta UN run inmediato (no N — evita spam), y vuelve al schedule normal.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 3 * * *", store=db, catch_up=true)
async fn cleanup_old_sessions(db: DbConn) { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Retry con backoff configurable
&lt;/h2&gt;

&lt;p&gt;Tres modos de backoff: &lt;code&gt;exponential&lt;/code&gt; (1s, 2s, 4s, 8s...), &lt;code&gt;linear&lt;/code&gt; (1s, 2s, 3s, 4s...), &lt;code&gt;constant&lt;/code&gt; (1s, 1s, 1s...). Con cap &lt;code&gt;max_secs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("*/10 * * * *",
    retry={ max=5, backoff="exponential", initial_secs=1, max_secs=60 })
async fn sync_external_api(db: DbConn) {
    // 1s → 2s → 4s → 8s → 16s (capeado a 60s si llegara más alto)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Python con Celery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@celery_app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;autoretry_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ConnectionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;retry_backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retry_backoff_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retry_jitter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sync_external_api&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Equivalente. Pero notá que Celery tenés que enumerar las excepciones que disparan retry, y el backoff es un bool en lugar de un kind enum. Fitz hace retry ante &lt;strong&gt;cualquier&lt;/strong&gt; error que devuelva la fn (consistente con el modelo Result del lenguaje), y el modo de backoff es explícito.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timezone real, no offset hardcoded
&lt;/h2&gt;

&lt;p&gt;Las DST changes son el bug que te despiertan a las 4 AM. Celery beat trabaja en UTC y vos tenés que recordar que entre marzo y noviembre tu cron de las 9 AM Buenos Aires se corre a las 12 UTC, pero el resto del año cambia. Tu cliente está en otro huso. Tu app vende a tres países más.&lt;/p&gt;

&lt;p&gt;Fitz acepta IANA timezones directamente:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 9 * * *", tz="America/Argentina/Buenos_Aires") async fn buenos_aires() { ... }
@cron("0 9 * * *", tz="America/New_York")              async fn new_york()      { ... }
@cron("0 9 * * *", tz="Asia/Tokyo")                    async fn tokyo()         { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cada uno corre a las 9 AM &lt;strong&gt;local&lt;/strong&gt; de su tz, con DST manejado por la lib subyacente (&lt;code&gt;chrono-tz&lt;/code&gt;). No conversiones a mano.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background jobs sin broker
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python
&lt;/span&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/signup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;send_welcome_email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# serializa args → Redis → worker
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Fitz
@background
async fn send_welcome_email(email: Str) {
    smtp.send(email, "Welcome!")
}

@post("/signup")
fn signup(creds: Credentials) -&amp;gt; User {
    let user = create_user(creds)
    spawn(send_welcome_email(user.email))  // tokio::spawn nativo, tipado
    return user
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El compilador exige que la fn esté decorada con &lt;code&gt;@background&lt;/code&gt; para autorizar el &lt;code&gt;spawn(...)&lt;/code&gt; — sin esto, el callsite tira error de tipo en build time. Y refina el retorno a &lt;code&gt;Future&amp;lt;Null&amp;gt;&lt;/code&gt; con el tipo concreto del target, no &lt;code&gt;Any&lt;/code&gt;. Si &lt;code&gt;send_welcome_email&lt;/code&gt; retorna &lt;code&gt;Result&amp;lt;()&amp;gt;&lt;/code&gt;, el &lt;code&gt;spawn(...)&lt;/code&gt; te lo da tipado.&lt;/p&gt;

&lt;p&gt;¿Cuándo NO usar &lt;code&gt;@background&lt;/code&gt; y volver a Celery?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Si necesitás que los jobs sobrevivan crashes del proceso → persistencia explícita con &lt;code&gt;store=db&lt;/code&gt; cubre cron jobs; para &lt;code&gt;@background&lt;/code&gt; con persistencia es deuda residual.&lt;/li&gt;
&lt;li&gt;Si necesitás distribuir jobs en N workers en N nodos → Celery con un broker compartido sigue siendo la respuesta. &lt;code&gt;@background&lt;/code&gt; corre en el proceso.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Para el 90% de servicios (cleanup nocturno, email transaccional, recálculo de KPIs, sync de cache) el modelo de Fitz alcanza y sobra.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron-only mode para systemd
&lt;/h2&gt;

&lt;p&gt;Para servicios que SOLO tienen jobs (sin HTTP), &lt;code&gt;fitz build&lt;/code&gt; produce un binario que arranca el scheduler y bloquea con ctrl+c:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 3 * * *") async fn cleanup() { ... }
@cron("*/15 * * * *") async fn sync() { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Como systemd unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Scheduled jobs for myapp&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/myapp-jobs&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;myapp&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cero brokers. El binario es 5 MB. &lt;code&gt;systemctl restart myapp-jobs&lt;/code&gt; y listo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paridad bit-a-bit &lt;code&gt;fitz run&lt;/code&gt; ↔ &lt;code&gt;fitz build&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Esto es lo que vuelve esto entregable: el binario que producís con &lt;code&gt;fitz build&lt;/code&gt; ejecuta el mismo scheduler que &lt;code&gt;fitz run&lt;/code&gt;, con el mismo cron expression parser, los mismos retries, la misma timezone. Misma sintaxis, mismas semánticas, sin "ah esto solo funciona en producción si configurás Celery".&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que Fitz NO te da (todavía)
&lt;/h2&gt;

&lt;p&gt;Soy honesto sobre dónde no llega:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Distribuir jobs en N workers/nodos compartiendo cola&lt;/strong&gt;. &lt;code&gt;@cron&lt;/code&gt; corre en el proceso. Si corrés dos binarios con el mismo &lt;code&gt;@cron&lt;/code&gt;, ambos disparan — bug, no feature. Para horizontal scaling de jobs, sigue siendo Celery (o NATS JetStream, o Temporal). Hay deuda explícita en el roadmap sobre locks distribuidos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI tipo Flower&lt;/strong&gt;. Los datos están en &lt;code&gt;fitz_cron_jobs&lt;/code&gt; y &lt;code&gt;fitz_cron_runs&lt;/code&gt;. Si querés dashboards, dashboards externos (Grafana, Metabase) cubren.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@background&lt;/code&gt; con persistencia entre restarts&lt;/strong&gt;. Para &lt;code&gt;@cron&lt;/code&gt; está en v0.11.2 (cierre 9.w.3.iter2). Para &lt;code&gt;@background&lt;/code&gt; arranca el spawn pero no sobrevive crash — diferido a iter3 si entra demanda.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancelación de jobs en flight&lt;/strong&gt;. Hoy no hay API para "cancelar todos los runs en cola de X job". El proceso muere → los runs en flight mueren con él.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si estás en uno de esos casos, Fitz no es la herramienta hoy. Si no, el modelo de un solo binario cubre el caso real con menos partes móviles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cierre
&lt;/h2&gt;

&lt;p&gt;El argumento para sumar Celery a una API en Python típicamente es: "pero después escala mejor." En el 90% de los servicios que escribí en la última década, ese "después" nunca llegó — la app pasó toda su vida útil con menos de 100 jobs por hora y nunca necesitó un cluster de workers.&lt;/p&gt;

&lt;p&gt;Para ese 90%, Fitz reemplaza 4 procesos + 3 librerías + 1 broker con un decorador. Cuando llegues al otro 10% donde necesitás scaling horizontal real, Celery sigue ahí — &lt;code&gt;from python import celery&lt;/code&gt; también está disponible, podés hacerlo tú mismo.&lt;/p&gt;

&lt;p&gt;Pero arrancar el proyecto con el modelo más simple posible y subir la complejidad solo cuando lo necesitás es el ciclo de feedback que Fitz quiere darte.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Próximo post de la serie&lt;/strong&gt;: &lt;strong&gt;"Auth con JWT, RBAC y token blacklist sin pegar 5 librerías: Fitz vs FastAPI + python-jose + passlib + Redis blacklist"&lt;/strong&gt; — el flow completo de auth, lado a lado.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Capítulo 30 de la guía&lt;/strong&gt; (Jobs sin Celery): &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;docs/guide.md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Cron jobs without Celery, Redis, or Beat: how Fitz puts a distributed scheduler inside the language</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Mon, 22 Jun 2026 23:10:13 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/cron-jobs-without-celery-redis-or-beat-how-fitz-puts-a-distributed-scheduler-inside-the-language-57if</link>
      <guid>https://dev.to/martin_palopoli/cron-jobs-without-celery-redis-or-beat-how-fitz-puts-a-distributed-scheduler-inside-the-language-57if</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;To run a task every 5 minutes in Python you need Celery + Redis + Celery Beat + a worker process + a dedicated Dockerfile. In Fitz it's a decorator. With retry, timezone, persistence, and catch-up inside the language.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The same story every time
&lt;/h2&gt;

&lt;p&gt;Your client is happy with the API. It's running. Then they ask for three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"Can we clean up expired sessions every night?"&lt;/li&gt;
&lt;li&gt;"Can the welcome email go out in the background so signup doesn't wait?"&lt;/li&gt;
&lt;li&gt;"The daily report needs to run at 9 AM Buenos Aires time."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three small asks. If you're in Python, the honest answer is: "OK, give me two days to add Celery."&lt;/p&gt;

&lt;h3&gt;
  
  
  The typical Python stack
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;celery redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;celery_app.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;celery&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Celery&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;celery.schedules&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;crontab&lt;/span&gt;

&lt;span class="n"&gt;celery_app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Celery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://redis:6379/0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://redis:6379/1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;celery_app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beat_schedule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cleanup-sessions-nightly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.tasks.cleanup_old_sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schedule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;crontab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily-report&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.tasks.generate_daily_report&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schedule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;crontab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# 9am Buenos Aires = 12:00 UTC
&lt;/span&gt;        &lt;span class="c1"&gt;# heads up: if DST shifts, this fires at the wrong time
&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;celery_app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UTC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tasks.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.celery_app&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;celery_app&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_session&lt;/span&gt;

&lt;span class="nd"&gt;@celery_app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autoretry_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="n"&gt;retry_backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cleanup_old_sessions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;get_session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM sessions WHERE expires_at &amp;lt; now()&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@celery_app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autoretry_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SMTPError&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="n"&gt;retry_backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_welcome_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;smtp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/signup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;send_welcome_email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# fire-and-forget via broker
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uvicorn api:app --host 0.0.0.0&lt;/span&gt;
  &lt;span class="na"&gt;celery-worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;celery -A celery_app worker --loglevel=info&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;celery-beat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;celery -A celery_app beat --loglevel=info&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;supervisord.conf&lt;/code&gt; or systemd unit to keep things alive when they crash.&lt;/li&gt;
&lt;li&gt;(Optional) &lt;code&gt;flower&lt;/code&gt; for visibility — a 4th process.&lt;/li&gt;
&lt;li&gt;Timezone conversions done by hand (Celery beat works in UTC, you have to compute the offset).&lt;/li&gt;
&lt;li&gt;When a worker crashes between hitting the broker and finishing the task: does it retry? Does it hang? Depends on how you configured &lt;code&gt;acks_late&lt;/code&gt; and &lt;code&gt;task_reject_on_worker_lost&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four processes in production. Three new libraries. A broker. New conventions. A day of setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing in Fitz
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 3 * * *", tz="UTC")
async fn cleanup_old_sessions(db: DbConn) {
    db.exec("DELETE FROM sessions WHERE expires_at &amp;lt; now()").await
}

@cron("0 9 * * *", tz="America/Argentina/Buenos_Aires")
async fn daily_report(db: DbConn) {
    // ...
}

@background
async fn send_welcome_email(email: Str) {
    // expensive thing
}

@post("/signup")
fn signup(creds: Credentials) -&amp;gt; User {
    let user = create_user(creds)
    spawn(send_welcome_email(user.email))  // typed fire-and-forget
    return user
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. One binary. One process. No broker. No dedicated worker. No beat. The scheduler runs inside the Fitz process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Python (Celery + Redis + Beat)&lt;/th&gt;
&lt;th&gt;Fitz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Initial setup&lt;/td&gt;
&lt;td&gt;4 files + 3 services + 1 broker&lt;/td&gt;
&lt;td&gt;1 decorator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedule&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;crontab(hour=3, minute=0)&lt;/code&gt; in config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@cron("0 3 * * *")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timezone&lt;/td&gt;
&lt;td&gt;UTC + manual offset&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tz="America/Argentina/Buenos_Aires"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry/backoff&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;autoretry_for=(...)&lt;/code&gt; + &lt;code&gt;retry_backoff=True&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;retry={max=3, backoff="exponential", initial_secs=1, max_secs=30}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run persistence&lt;/td&gt;
&lt;td&gt;Redis result backend + viewing via Flower&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;store=db&lt;/code&gt;, table auto-created in Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catch-up of missed runs&lt;/td&gt;
&lt;td&gt;Not native&lt;/td&gt;
&lt;td&gt;&lt;code&gt;catch_up=true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background fire-and-forget&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.delay(arg)&lt;/code&gt; via broker&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spawn(fn_call)&lt;/code&gt; direct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type-checked args&lt;/td&gt;
&lt;td&gt;None (everything JSON-serialized)&lt;/td&gt;
&lt;td&gt;Static —&lt;code&gt;spawn&lt;/code&gt; requires &lt;code&gt;@background&lt;/code&gt; and refines to &lt;code&gt;Future&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production processes&lt;/td&gt;
&lt;td&gt;api + worker + beat + redis&lt;/td&gt;
&lt;td&gt;api&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker images&lt;/td&gt;
&lt;td&gt;3 (api, worker, beat) + redis&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Opt-in persistence with &lt;code&gt;store=db&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When you want run history for auditing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 3 * * *", store=db, retry={max=3, backoff="exponential"})
async fn cleanup_old_sessions(db: DbConn) {
    db.exec("DELETE FROM sessions WHERE expires_at &amp;lt; now()").await
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On boot, Fitz creates (if missing) &lt;code&gt;fitz_cron_jobs&lt;/code&gt; and &lt;code&gt;fitz_cron_runs&lt;/code&gt;. Each attempt is persisted with &lt;code&gt;started_at&lt;/code&gt;/&lt;code&gt;finished_at&lt;/code&gt;/&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;attempt&lt;/code&gt;/&lt;code&gt;error&lt;/code&gt;. You query with plain SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fitz_cron_runs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Flower install. No Sentry webhook. The DB you already have.&lt;/p&gt;

&lt;h3&gt;
  
  
  Catch-up
&lt;/h3&gt;

&lt;p&gt;If the binary was down between 3 AM and 7 AM and the cron was at 3 AM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Celery beat&lt;/strong&gt;: the run is lost. The task won't fire again until the next midnight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fitz with &lt;code&gt;catch_up=true&lt;/code&gt;&lt;/strong&gt;: on boot, it sees there was a missed run between &lt;code&gt;last_run_at&lt;/code&gt; and &lt;code&gt;now&lt;/code&gt;, fires ONE immediate run (not N — avoids spam), then resumes the normal schedule.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 3 * * *", store=db, catch_up=true)
async fn cleanup_old_sessions(db: DbConn) { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configurable retry with backoff
&lt;/h2&gt;

&lt;p&gt;Three backoff modes: &lt;code&gt;exponential&lt;/code&gt; (1s, 2s, 4s, 8s...), &lt;code&gt;linear&lt;/code&gt; (1s, 2s, 3s, 4s...), &lt;code&gt;constant&lt;/code&gt; (1s, 1s, 1s...). With cap &lt;code&gt;max_secs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("*/10 * * * *",
    retry={ max=5, backoff="exponential", initial_secs=1, max_secs=60 })
async fn sync_external_api(db: DbConn) {
    // 1s → 2s → 4s → 8s → 16s (capped at 60s if it would go higher)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Python with Celery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@celery_app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;autoretry_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ConnectionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;retry_backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retry_backoff_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retry_jitter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sync_external_api&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Equivalent. But notice Celery requires you to enumerate the exceptions that trigger retry, and backoff is a boolean instead of a kind enum. Fitz retries on &lt;strong&gt;any&lt;/strong&gt; error the fn returns (consistent with the language's Result model), and the backoff mode is explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real timezone, not hardcoded offsets
&lt;/h2&gt;

&lt;p&gt;DST changes are the bug that wakes you up at 4 AM. Celery beat works in UTC and you have to remember that between March and November your 9 AM Buenos Aires cron runs at 12 UTC, but the rest of the year it shifts. Your client is in another timezone. Your app sells in three more countries.&lt;/p&gt;

&lt;p&gt;Fitz accepts IANA timezones directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 9 * * *", tz="America/Argentina/Buenos_Aires") async fn buenos_aires() { ... }
@cron("0 9 * * *", tz="America/New_York")              async fn new_york()      { ... }
@cron("0 9 * * *", tz="Asia/Tokyo")                    async fn tokyo()         { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each runs at 9 AM &lt;strong&gt;local&lt;/strong&gt; time in its tz, with DST handled by the underlying lib (&lt;code&gt;chrono-tz&lt;/code&gt;). No manual conversions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background jobs without a broker
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python
&lt;/span&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/signup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;send_welcome_email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# serialize args → Redis → worker
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Fitz
@background
async fn send_welcome_email(email: Str) {
    smtp.send(email, "Welcome!")
}

@post("/signup")
fn signup(creds: Credentials) -&amp;gt; User {
    let user = create_user(creds)
    spawn(send_welcome_email(user.email))  // native tokio::spawn, typed
    return user
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compiler requires the fn be decorated with &lt;code&gt;@background&lt;/code&gt; to authorize the &lt;code&gt;spawn(...)&lt;/code&gt; — without it, the callsite throws a type error at build time. And it refines the return to &lt;code&gt;Future&amp;lt;Null&amp;gt;&lt;/code&gt; with the concrete type of the target, not &lt;code&gt;Any&lt;/code&gt;. If &lt;code&gt;send_welcome_email&lt;/code&gt; returns &lt;code&gt;Result&amp;lt;()&amp;gt;&lt;/code&gt;, the &lt;code&gt;spawn(...)&lt;/code&gt; gives it to you typed.&lt;/p&gt;

&lt;p&gt;When NOT to use &lt;code&gt;@background&lt;/code&gt; and go back to Celery?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you need jobs to survive process crashes → explicit persistence with &lt;code&gt;store=db&lt;/code&gt; covers cron jobs; for &lt;code&gt;@background&lt;/code&gt; with persistence, that's residual debt.&lt;/li&gt;
&lt;li&gt;If you need to distribute jobs across N workers on N nodes → Celery with a shared broker is still the answer. &lt;code&gt;@background&lt;/code&gt; runs in-process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For 90% of services (nightly cleanup, transactional email, KPI recompute, cache sync) Fitz's model is enough and then some.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron-only mode for systemd
&lt;/h2&gt;

&lt;p&gt;For services that ONLY have jobs (no HTTP), &lt;code&gt;fitz build&lt;/code&gt; produces a binary that starts the scheduler and blocks on ctrl+c:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@cron("0 3 * * *") async fn cleanup() { ... }
@cron("*/15 * * * *") async fn sync() { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a systemd unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Scheduled jobs for myapp&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/myapp-jobs&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;myapp&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero brokers. The binary is 5 MB. &lt;code&gt;systemctl restart myapp-jobs&lt;/code&gt; and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bit-for-bit parity &lt;code&gt;fitz run&lt;/code&gt; ↔ &lt;code&gt;fitz build&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is what makes it shippable: the binary produced by &lt;code&gt;fitz build&lt;/code&gt; runs the same scheduler as &lt;code&gt;fitz run&lt;/code&gt;, with the same cron expression parser, the same retries, the same timezone. Same syntax, same semantics, no "oh, this only works in production if you configure Celery."&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fitz does NOT give you (yet)
&lt;/h2&gt;

&lt;p&gt;Being honest about where it doesn't reach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Distributing jobs across N workers/nodes sharing a queue&lt;/strong&gt;. &lt;code&gt;@cron&lt;/code&gt; runs in-process. If you run two binaries with the same &lt;code&gt;@cron&lt;/code&gt;, both fire — bug, not feature. For horizontal scaling of jobs, it's still Celery (or NATS JetStream, or Temporal). There's explicit debt in the roadmap about distributed locks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Flower-like UI&lt;/strong&gt;. The data is in &lt;code&gt;fitz_cron_jobs&lt;/code&gt; and &lt;code&gt;fitz_cron_runs&lt;/code&gt;. If you want dashboards, external dashboards (Grafana, Metabase) cover it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@background&lt;/code&gt; with persistence across restarts&lt;/strong&gt;. For &lt;code&gt;@cron&lt;/code&gt; it's in v0.11.2 (close of 9.w.3.iter2). For &lt;code&gt;@background&lt;/code&gt; the spawn fires but doesn't survive crashes — deferred to iter3 if demand appears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-flight job cancellation&lt;/strong&gt;. Today there's no API to "cancel all queued runs of X job". Process dies → in-flight runs die with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're in one of those cases, Fitz isn't the tool today. If not, the one-binary model covers the real case with fewer moving parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The argument for adding Celery to a Python API is typically: "but later it scales better." In 90% of services I've shipped over the past decade, that "later" never came — the app spent its entire lifetime with fewer than 100 jobs per hour and never needed a worker cluster.&lt;/p&gt;

&lt;p&gt;For that 90%, Fitz replaces 4 processes + 3 libraries + 1 broker with one decorator. When you reach the other 10% where you need real horizontal scaling, Celery is still there — &lt;code&gt;from python import celery&lt;/code&gt; is also available, you can do it yourself.&lt;/p&gt;

&lt;p&gt;But starting the project with the simplest possible model and raising complexity only when you need it is the feedback loop Fitz wants to give you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next post in the series&lt;/strong&gt;: &lt;strong&gt;"Auth with JWT, RBAC, and token blacklist without gluing 5 libraries: Fitz vs FastAPI + python-jose + passlib + Redis blacklist"&lt;/strong&gt; — the full auth flow, side by side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Chapter 30 of the guide&lt;/strong&gt; (Jobs without Celery): &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;docs/guide.md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Real-time chat with Fitz: typed WebSockets and AsyncAPI auto-generated</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Wed, 17 Jun 2026 12:33:06 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/real-time-chat-with-fitz-typed-websockets-and-asyncapi-auto-generated-l8f</link>
      <guid>https://dev.to/martin_palopoli/real-time-chat-with-fitz-typed-websockets-and-asyncapi-auto-generated-l8f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Add a live click dashboard to the URL shortener from Part 2 using &lt;code&gt;@ws("/path")&lt;/code&gt;, &lt;code&gt;WsConn&amp;lt;T&amp;gt;&lt;/code&gt;, and the AsyncAPI 3.0 schema Fitz generates automatically. Same auth, same types, same binary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The promise
&lt;/h2&gt;

&lt;p&gt;In Part 2 we built a URL shortener: HTTP endpoints, Postgres ORM, JWT auth, native binary. Real and shippable, but every interaction is a request-response. Refresh the stats page to see the new click. Like 1998 again.&lt;/p&gt;

&lt;p&gt;Today we make it live. When somebody clicks a short URL:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The HTTP handler still redirects and increments the counter (same as before).&lt;/li&gt;
&lt;li&gt;A WebSocket subscribed to &lt;code&gt;/dashboard&lt;/code&gt; gets a typed message with the click event.&lt;/li&gt;
&lt;li&gt;The dashboard updates without polling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full diff against Part 2's code: &lt;strong&gt;~40 lines&lt;/strong&gt;. No new library installs. No &lt;code&gt;websockets&lt;/code&gt; package, no &lt;code&gt;socketio&lt;/code&gt; server, no &lt;code&gt;redis&lt;/code&gt; for pub/sub. Just &lt;code&gt;@ws&lt;/code&gt; on a function.&lt;/p&gt;

&lt;h2&gt;
  
  
  The typed WebSocket model
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type ClickEvent {
    code: Str,
    target_url: Str,
    timestamp: Str,
}

@authenticated
@ws("/dashboard")
async fn dashboard(conn: WsConn&amp;lt;ClickEvent&amp;gt;, user: User) {
    log.info("dashboard.connected", { user_email: user.email })
    loop {
        let msg = match conn.recv() {
            Ok(m) =&amp;gt; m,
            Err(_) =&amp;gt; break,   // client disconnected
        }
        // For now we just echo back. In a real dashboard we'd ignore
        // incoming and only push out — `broadcast` is below.
        conn.send(msg)
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in one decorator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@ws("/dashboard")&lt;/code&gt;&lt;/strong&gt; — register a WebSocket endpoint at that path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WsConn&amp;lt;ClickEvent&amp;gt;&lt;/code&gt;&lt;/strong&gt; — the typed connection. Every frame in or out is marshalled as &lt;code&gt;ClickEvent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@authenticated&lt;/code&gt;&lt;/strong&gt; — auth runs &lt;strong&gt;before&lt;/strong&gt; the WebSocket upgrade. Bad token → 401, no socket opened, no network resources wasted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;@ws&lt;/code&gt; understands the &lt;code&gt;auth_provider&lt;/code&gt; from Part 2. The same JWT verification function gates the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-marshalling, both directions
&lt;/h2&gt;

&lt;p&gt;The HTTP body deserialization from Part 1 — type-checked JSON, defaults applied, missing fields detected, extras rejected — also works for WebSocket frames.&lt;/p&gt;

&lt;p&gt;When the dashboard sends a frame, Fitz serializes the &lt;code&gt;ClickEvent&lt;/code&gt; to JSON and sends. When a frame comes in, Fitz deserializes the JSON into &lt;code&gt;ClickEvent&lt;/code&gt; and validates. If a frame is malformed, the &lt;code&gt;recv()&lt;/code&gt; returns &lt;code&gt;Err&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You don't write a single &lt;code&gt;json.dumps&lt;/code&gt;/&lt;code&gt;json.loads&lt;/code&gt; call. The compiler did it.&lt;/p&gt;

&lt;p&gt;In contrast: the typical Python WebSocket loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# typical FastAPI / websockets server
&lt;/span&gt;&lt;span class="nd"&gt;@app.websocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dashboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&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="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ClickEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# pydantic
&lt;/span&gt;        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;WebSocketDisconnect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)}))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump_json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@ws("/dashboard")
async fn dashboard(conn: WsConn&amp;lt;ClickEvent&amp;gt;) {
    loop {
        let msg = match conn.recv() {
            Ok(m) =&amp;gt; m,
            Err(_) =&amp;gt; break,
        }
        conn.send(msg)
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Python version has the same logic but you have to spell it. The Fitz version has the logic in the type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broadcasting clicks live
&lt;/h2&gt;

&lt;p&gt;Now we wire the click event from the HTTP redirect to the dashboard. The trick: &lt;code&gt;broadcast&lt;/code&gt; on a &lt;code&gt;WsConn&amp;lt;T&amp;gt;&lt;/code&gt; sends to &lt;strong&gt;every&lt;/strong&gt; connection on the same endpoint, not just the one that called it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@get("/{code}")
async fn redirect(db: DbConn, code: Str) -&amp;gt; Result&amp;lt;HttpResponse&amp;gt; {
    let link: Link = match Link.where(fn(l) =&amp;gt; l.code == code).first(db).await {
        Ok(l) =&amp;gt; l,
        Err(_) =&amp;gt; return Err("not found"),
    }
    // Same as Part 2: spawn a background increment.
    spawn(increment_clicks(db, link.id))
    // New: broadcast the click event to the dashboard.
    spawn(notify_dashboard(link.code, link.target_url))
    return Ok(redirect_to(link.target_url))
}

@background
async fn notify_dashboard(code: Str, target_url: Str) {
    let event = ClickEvent {
        code: code,
        target_url: target_url,
        timestamp: now_iso(),
    }
    // The runtime keeps the broadcaster for `/dashboard` reachable
    // from anywhere via the typed handle.
    ws.broadcast("/dashboard", event)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The redirect handler hasn't changed in shape — it still returns the redirect response. We added one &lt;code&gt;spawn&lt;/code&gt;. The background fn &lt;code&gt;notify_dashboard&lt;/code&gt; calls &lt;code&gt;ws.broadcast&lt;/code&gt; which fans out to every connection currently subscribed to &lt;code&gt;/dashboard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The broadcast is fire-and-forget. If no dashboards are connected, the call is a no-op. If 50 dashboards are connected, all 50 get the event. The runtime handles the list of active connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heartbeat, baked in
&lt;/h2&gt;

&lt;p&gt;WebSocket connections silently die in production. Some proxy decides 60 seconds of idle is too long, drops the TCP connection, and your client thinks it's still connected. Every WebSocket library has to add heartbeats; in Fitz it's a flag on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43929, ws_heartbeat_secs=30)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every 30 seconds, the runtime sends a &lt;code&gt;Ping&lt;/code&gt; frame on every WebSocket connection. If the client doesn't respond with &lt;code&gt;Pong&lt;/code&gt;, the runtime considers the connection dead and closes it cleanly. Most proxies, including Nginx and Cloudflare, accept this as "still alive" and don't drop it.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;ws_heartbeat_secs=0&lt;/code&gt; to disable (default is 30).&lt;/p&gt;

&lt;p&gt;This is the kind of feature you'd never bother to add yourself in a small project, then spend a Sunday debugging when production breaks. It's defaulted on for the same reason &lt;code&gt;tcp_keepalive&lt;/code&gt; is defaulted on.&lt;/p&gt;

&lt;h2&gt;
  
  
  AsyncAPI generated automatically
&lt;/h2&gt;

&lt;p&gt;OpenAPI describes HTTP services. &lt;strong&gt;AsyncAPI&lt;/strong&gt; is its event-driven sibling — same schema model, but for WebSockets, Kafka, MQTT, etc. Fitz generates AsyncAPI 3.0 automatically, the same way it generates OpenAPI for HTTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:8080/asyncapi.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"asyncapi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shortener"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/dashboard"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ClickEvent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"target_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"target_url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"operations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/dashboard.receive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/dashboard.send"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"components"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bearerAuth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bearerFormat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the full event API of your service. &lt;strong&gt;I don't know another language that auto-generates AsyncAPI from typed source.&lt;/strong&gt; The AsyncAPI ecosystem has client generators (TypeScript, Java, Python), which means: drop the schema into &lt;a href="https://studio.asyncapi.com/" rel="noopener noreferrer"&gt;studio.asyncapi.com&lt;/a&gt; or &lt;code&gt;asyncapi generate&lt;/code&gt;, get a typed client for your front-end.&lt;/p&gt;

&lt;p&gt;If you mark a server with &lt;code&gt;@server(docs=false)&lt;/code&gt;, neither OpenAPI nor AsyncAPI is exposed. Defaults are on because the cost is small and the value is large.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal browser client
&lt;/h2&gt;

&lt;p&gt;A 30-line vanilla JS client to test the dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"token"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"paste JWT token"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"connect"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Connect&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"events"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;onclick&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&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;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ws://localhost:8080/dashboard&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="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;token&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="c1"&gt;// Note: browser WebSocket constructors don't accept custom headers&lt;/span&gt;
    &lt;span class="c1"&gt;// directly. In production, you'd put the token in a query param&lt;/span&gt;
    &lt;span class="c1"&gt;// (?token=...) and have the auth_provider read it from there.&lt;/span&gt;
    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&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="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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&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;data&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;li&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;li&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target_url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;prepend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&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="o"&gt;=&amp;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;ws error&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;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onclose&lt;/span&gt; &lt;span class="o"&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="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;disconnected&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put this in a file, open in the browser, paste a JWT, click the connect button. Then in another terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Get a token (from Part 2's POST /login)&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080/login &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"ada@example.com","password":"secret-ada-123"}'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .token&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Create a short URL&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8080/shorten &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"target_url":"https://github.com/Thegreekman76/fitz"}'&lt;/span&gt;

&lt;span class="c"&gt;# Click the short URL — the browser's `events` list updates instantly&lt;/span&gt;
curl &lt;span class="nt"&gt;-I&lt;/span&gt; localhost:8080/abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt; populates with the click event the moment the redirect happens. The browser was holding open a WebSocket; the click event got broadcast; the JS callback rendered it. Live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations and trade-offs
&lt;/h2&gt;

&lt;p&gt;Honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One type per endpoint&lt;/strong&gt;. &lt;code&gt;WsConn&amp;lt;ClickEvent&amp;gt;&lt;/code&gt; means every frame in and out is &lt;code&gt;ClickEvent&lt;/code&gt;. If you need both directions to be different types (&lt;code&gt;In = ChatMessage&lt;/code&gt;, &lt;code&gt;Out = ServerEvent&lt;/code&gt;), the workaround is to make the type a sum (&lt;code&gt;union&lt;/code&gt;-style) — declare a wider &lt;code&gt;Event&lt;/code&gt; type with optional fields. The cleaner solution (&lt;code&gt;WsConn&amp;lt;In, Out&amp;gt;&lt;/code&gt; two-type generic) is on the roadmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No rooms / channels within an endpoint&lt;/strong&gt;. &lt;code&gt;broadcast&lt;/code&gt; goes to every connection on the endpoint. If you want "broadcast only to users subscribed to project 42", you maintain a &lt;code&gt;Map&amp;lt;Int, Vec&amp;lt;WsConn&amp;gt;&amp;gt;&lt;/code&gt; yourself or split into multiple endpoints (one per project — works fine for low counts).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No reconnect with state replay&lt;/strong&gt;. If the dashboard disconnects, on reconnect it sees new events only — there's no "give me the last 30 seconds I missed". Building that needs an event log (a Postgres table polled, or Redis Streams). Outside the WebSocket layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binary frames&lt;/strong&gt; are supported via &lt;code&gt;WsConn&amp;lt;Bytes&amp;gt;&lt;/code&gt;, but I haven't talked about them here. Useful for file uploads or audio streaming; the AsyncAPI emits &lt;code&gt;format: binary&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the honest gaps. The 90% of WebSocket use cases (real-time updates, chat, live dashboards, multi-user collaborative editing for a single document) are covered today.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this composes with the rest of Fitz
&lt;/h2&gt;

&lt;p&gt;You can mix WebSockets with everything else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt;: &lt;code&gt;@authenticated&lt;/code&gt;/&lt;code&gt;@admin&lt;/code&gt;/&lt;code&gt;@requires("role")&lt;/code&gt; work on &lt;code&gt;@ws&lt;/code&gt;, evaluated before the upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware&lt;/strong&gt;: middlewares run before the upgrade for things like rate limiting per IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORM&lt;/strong&gt;: the WebSocket handler can take a &lt;code&gt;DbConn&lt;/code&gt; and query the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async&lt;/strong&gt;: &lt;code&gt;recv&lt;/code&gt;/&lt;code&gt;send&lt;/code&gt;/&lt;code&gt;broadcast&lt;/code&gt; are awaitable; combine with HTTP calls or Postgres queries inside the loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron / spawn&lt;/strong&gt;: cron jobs can &lt;code&gt;ws.broadcast("/topic", event)&lt;/code&gt; to push periodic updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry&lt;/strong&gt;: the auth check before upgrade emits a trace span; subsequent broadcasts can be traced too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same language, same types, same binary. The WebSocket isn't a separate world.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'd need next for a real product
&lt;/h2&gt;

&lt;p&gt;The dashboard above is enough to demo "real-time URL shortener clicks". For a production product you'd extend with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-user filtering&lt;/strong&gt;: only push clicks for URLs the user created. Trivial — check &lt;code&gt;user.email == link.user_email&lt;/code&gt; before broadcasting, or split into one endpoint per user with the email in the path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregation&lt;/strong&gt;: don't send every click; send a rate of clicks per code per second. Maintain state in the handler, send aggregated frames every 1 second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend framework integration&lt;/strong&gt;: TypeScript types from the AsyncAPI schema. Run &lt;code&gt;asyncapi generate&lt;/code&gt; once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these change the Fitz code structure. They're all "edit the broadcast call site".&lt;/p&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="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# Reopen the terminal, then:&lt;/span&gt;
git clone https://github.com/Thegreekman76/fitz.git
&lt;span class="nb"&gt;cd &lt;/span&gt;fitz/boilerplates/api-websocket

&lt;span class="c"&gt;# Read the README, run with docker compose or `fitz dev`&lt;/span&gt;
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For VSCode (recommended — &lt;code&gt;WsConn&amp;lt;T&amp;gt;&lt;/code&gt; hover, autocomplete on &lt;code&gt;conn.send&lt;/code&gt;/&lt;code&gt;recv&lt;/code&gt;/&lt;code&gt;broadcast&lt;/code&gt;): grab the &lt;code&gt;fitz-lang-&amp;lt;platform&amp;gt;.vsix&lt;/code&gt; from the &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; and &lt;code&gt;code --install-extension fitz-lang-&amp;lt;platform&amp;gt;.vsix --force&lt;/code&gt;. The Language Server is bundled.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;api-websocket&lt;/code&gt; boilerplate is a typed chat server. Pretty much the same shape as the dashboard above — &lt;code&gt;loop { recv; broadcast }&lt;/code&gt; with auth.&lt;/p&gt;

&lt;p&gt;For the full URL shortener + live dashboard, the &lt;code&gt;api-orm-full&lt;/code&gt; boilerplate has the whole thing assembled (HTTP routes + ORM + WebSocket dashboard + cron job + JWT auth) in ~250 lines total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;code&gt;api-websocket&lt;/code&gt; boilerplate&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-websocket" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/tree/main/boilerplates/api-websocket&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;code&gt;api-orm-full&lt;/code&gt; boilerplate&lt;/strong&gt; (the full thing): &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs and course&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Guide chapter on WebSockets&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/#29-websockets&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build something with this, write to me. I want to see it.&lt;/p&gt;

&lt;p&gt;Until the next one.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>websockets</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Chat en tiempo real con Fitz: WebSockets tipados y AsyncAPI auto-generado</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Wed, 17 Jun 2026 12:32:37 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/chat-en-tiempo-real-con-fitz-websockets-tipados-y-asyncapi-auto-generado-5750</link>
      <guid>https://dev.to/martin_palopoli/chat-en-tiempo-real-con-fitz-websockets-tipados-y-asyncapi-auto-generado-5750</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Sumá un dashboard de clicks en vivo al acortador de URLs de la Parte 2 usando &lt;code&gt;@ws("/path")&lt;/code&gt;, &lt;code&gt;WsConn&amp;lt;T&amp;gt;&lt;/code&gt; y el schema AsyncAPI 3.0 que Fitz genera automáticamente. Misma auth, mismos types, mismo binario.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  La promesa
&lt;/h2&gt;

&lt;p&gt;En la Parte 2 construimos un acortador de URLs: endpoints HTTP, ORM Postgres, auth JWT, binario nativo. Real y shippeable, pero cada interacción es request-response. Refrescá la página de stats para ver el click nuevo. Como 1998 otra vez.&lt;/p&gt;

&lt;p&gt;Hoy lo hacemos vivo. Cuando alguien clickea un URL corto:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;El handler HTTP sigue redirigiendo e incrementando el counter (igual que antes).&lt;/li&gt;
&lt;li&gt;Un WebSocket suscrito a &lt;code&gt;/dashboard&lt;/code&gt; recibe un mensaje tipado con el evento de click.&lt;/li&gt;
&lt;li&gt;El dashboard se actualiza sin polling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;El diff completo contra el código de la Parte 2: &lt;strong&gt;~40 líneas&lt;/strong&gt;. Sin nuevos installs de librería. Sin paquete &lt;code&gt;websockets&lt;/code&gt;, sin server &lt;code&gt;socketio&lt;/code&gt;, sin &lt;code&gt;redis&lt;/code&gt; para pub/sub. Solo &lt;code&gt;@ws&lt;/code&gt; sobre una función.&lt;/p&gt;

&lt;h2&gt;
  
  
  El modelo de WebSocket tipado
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type ClickEvent {
    code: Str,
    target_url: Str,
    timestamp: Str,
}

@authenticated
@ws("/dashboard")
async fn dashboard(conn: WsConn&amp;lt;ClickEvent&amp;gt;, user: User) {
    log.info("dashboard.connected", { user_email: user.email })
    loop {
        let msg = match conn.recv() {
            Ok(m) =&amp;gt; m,
            Err(_) =&amp;gt; break,   // cliente desconectó
        }
        // Por ahora solo hacemos echo. En un dashboard real ignoraríamos
        // los incoming y solo pusheríamos out — `broadcast` está abajo.
        conn.send(msg)
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres cosas en un decorador:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@ws("/dashboard")&lt;/code&gt;&lt;/strong&gt; — registrar un endpoint WebSocket en ese path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WsConn&amp;lt;ClickEvent&amp;gt;&lt;/code&gt;&lt;/strong&gt; — la conexión tipada. Cada frame in o out se marshallea como &lt;code&gt;ClickEvent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@authenticated&lt;/code&gt;&lt;/strong&gt; — la auth corre &lt;strong&gt;antes&lt;/strong&gt; del upgrade WebSocket. Token malo → 401, sin socket abierto, sin recursos de red gastados.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;@ws&lt;/code&gt; entiende el &lt;code&gt;auth_provider&lt;/code&gt; de la Parte 2. La misma función de verificación de JWT gatea el dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-marshalling, en ambas direcciones
&lt;/h2&gt;

&lt;p&gt;La deserialización del body HTTP de la Parte 1 — JSON type-checked, defaults aplicados, fields faltantes detectados, extras rechazados — también funciona para frames WebSocket.&lt;/p&gt;

&lt;p&gt;Cuando el dashboard envía un frame, Fitz serializa el &lt;code&gt;ClickEvent&lt;/code&gt; a JSON y lo envía. Cuando un frame llega, Fitz deserializa el JSON en &lt;code&gt;ClickEvent&lt;/code&gt; y valida. Si un frame está malformado, el &lt;code&gt;recv()&lt;/code&gt; retorna &lt;code&gt;Err&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No escribís ni una sola llamada a &lt;code&gt;json.dumps&lt;/code&gt;/&lt;code&gt;json.loads&lt;/code&gt;. El compilador lo hizo.&lt;/p&gt;

&lt;p&gt;En contraste: el loop típico de WebSocket en Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# server típico de FastAPI / websockets
&lt;/span&gt;&lt;span class="nd"&gt;@app.websocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dashboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&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="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ClickEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# pydantic
&lt;/span&gt;        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;WebSocketDisconnect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)}))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump_json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@ws("/dashboard")
async fn dashboard(conn: WsConn&amp;lt;ClickEvent&amp;gt;) {
    loop {
        let msg = match conn.recv() {
            Ok(m) =&amp;gt; m,
            Err(_) =&amp;gt; break,
        }
        conn.send(msg)
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La versión Python tiene la misma lógica pero tenés que deletrearla. La versión Fitz tiene la lógica en el type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broadcasteando clicks en vivo
&lt;/h2&gt;

&lt;p&gt;Ahora cableamos el evento de click del redirect HTTP al dashboard. El truco: &lt;code&gt;broadcast&lt;/code&gt; sobre un &lt;code&gt;WsConn&amp;lt;T&amp;gt;&lt;/code&gt; envía a &lt;strong&gt;cada&lt;/strong&gt; conexión sobre el mismo endpoint, no solo a la que lo llamó.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@get("/{code}")
async fn redirect(db: DbConn, code: Str) -&amp;gt; Result&amp;lt;HttpResponse&amp;gt; {
    let link: Link = match Link.where(fn(l) =&amp;gt; l.code == code).first(db).await {
        Ok(l) =&amp;gt; l,
        Err(_) =&amp;gt; return Err("not found"),
    }
    // Igual que la Parte 2: spawnear un increment background.
    spawn(increment_clicks(db, link.id))
    // Nuevo: broadcastear el click event al dashboard.
    spawn(notify_dashboard(link.code, link.target_url))
    return Ok(redirect_to(link.target_url))
}

@background
async fn notify_dashboard(code: Str, target_url: Str) {
    let event = ClickEvent {
        code: code,
        target_url: target_url,
        timestamp: now_iso(),
    }
    // El runtime mantiene el broadcaster para `/dashboard` accesible
    // desde cualquier lugar vía el handle tipado.
    ws.broadcast("/dashboard", event)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El handler de redirect no cambió en shape — sigue retornando el response de redirect. Sumamos un &lt;code&gt;spawn&lt;/code&gt;. La fn background &lt;code&gt;notify_dashboard&lt;/code&gt; llama &lt;code&gt;ws.broadcast&lt;/code&gt; que fanout a cada conexión actualmente suscrita a &lt;code&gt;/dashboard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;El broadcast es fire-and-forget. Si no hay dashboards conectados, el call es no-op. Si hay 50 dashboards conectados, los 50 reciben el evento. El runtime maneja la lista de conexiones activas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heartbeat, horneado
&lt;/h2&gt;

&lt;p&gt;Las conexiones WebSocket mueren silenciosamente en producción. Algún proxy decide que 60 segundos de idle es demasiado, droppea la conexión TCP, y tu cliente cree que sigue conectado. Cada librería WebSocket tiene que sumar heartbeats; en Fitz es una flag sobre el server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43929, ws_heartbeat_secs=30)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cada 30 segundos, el runtime envía un frame &lt;code&gt;Ping&lt;/code&gt; sobre cada conexión WebSocket. Si el cliente no responde con &lt;code&gt;Pong&lt;/code&gt;, el runtime considera la conexión muerta y la cierra limpia. La mayoría de los proxies, incluyendo Nginx y Cloudflare, aceptan esto como "todavía vivo" y no la droppean.&lt;/p&gt;

&lt;p&gt;Seteá &lt;code&gt;ws_heartbeat_secs=0&lt;/code&gt; para desactivar (default es 30).&lt;/p&gt;

&lt;p&gt;Este es el tipo de feature que nunca te tomarías el trabajo de agregar en un proyecto chico, después pasarías un domingo debuggeando cuando se rompe en producción. Default on por la misma razón que &lt;code&gt;tcp_keepalive&lt;/code&gt; está default on.&lt;/p&gt;

&lt;h2&gt;
  
  
  AsyncAPI generado automático
&lt;/h2&gt;

&lt;p&gt;OpenAPI describe servicios HTTP. &lt;strong&gt;AsyncAPI&lt;/strong&gt; es su hermano event-driven — mismo modelo de schema, pero para WebSockets, Kafka, MQTT, etc. Fitz genera AsyncAPI 3.0 automático, de la misma forma que genera OpenAPI para HTTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:8080/asyncapi.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"asyncapi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shortener"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/dashboard"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ClickEvent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"target_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"target_url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"operations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/dashboard.receive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"/dashboard.send"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"components"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bearerAuth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bearerFormat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Este es el API de eventos completo de tu servicio. &lt;strong&gt;No conozco otro lenguaje que auto-genere AsyncAPI desde código tipado.&lt;/strong&gt; El ecosistema de AsyncAPI tiene generadores de clientes (TypeScript, Java, Python), lo que significa: dropeá el schema en &lt;a href="https://studio.asyncapi.com/" rel="noopener noreferrer"&gt;studio.asyncapi.com&lt;/a&gt; o &lt;code&gt;asyncapi generate&lt;/code&gt;, conseguí un cliente tipado para tu front-end.&lt;/p&gt;

&lt;p&gt;Si marcás un server con &lt;code&gt;@server(docs=false)&lt;/code&gt;, ni OpenAPI ni AsyncAPI se exponen. Los defaults están on porque el costo es chico y el valor es grande.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un cliente browser mínimo
&lt;/h2&gt;

&lt;p&gt;Un cliente vanilla JS de 30 líneas para probar el dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"token"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"pegá JWT token"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"connect"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Conectar&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"events"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;onclick&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&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;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ws://localhost:8080/dashboard&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="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;token&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="c1"&gt;// Nota: los constructores de WebSocket del browser no aceptan headers&lt;/span&gt;
    &lt;span class="c1"&gt;// custom directamente. En producción, pondrías el token en un query&lt;/span&gt;
    &lt;span class="c1"&gt;// param (?token=...) y harías que el auth_provider lo lea de ahí.&lt;/span&gt;
    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&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="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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&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;data&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;li&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;li&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target_url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;prepend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&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="o"&gt;=&amp;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;ws error&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;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onclose&lt;/span&gt; &lt;span class="o"&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="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;desconectado&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Poné esto en un archivo, abrí en el browser, pegá un JWT, clickeá el botón connect. Después en otra terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Conseguí un token (del POST /login de la Parte 2)&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080/login &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"ada@example.com","password":"secret-ada-123"}'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .token&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Creá un URL corto&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8080/shorten &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"target_url":"https://github.com/Thegreekman76/fitz"}'&lt;/span&gt;

&lt;span class="c"&gt;# Clickeá el URL corto — la lista `events` del browser se actualiza al instante&lt;/span&gt;
curl &lt;span class="nt"&gt;-I&lt;/span&gt; localhost:8080/abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt; del dashboard se popula con el click event en el momento que el redirect ocurre. El browser tenía abierto un WebSocket; el click event se broadcasteó; el callback JS lo renderizó. En vivo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitaciones y trade-offs
&lt;/h2&gt;

&lt;p&gt;Honestamente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Un tipo por endpoint&lt;/strong&gt;. &lt;code&gt;WsConn&amp;lt;ClickEvent&amp;gt;&lt;/code&gt; significa que cada frame in y out es &lt;code&gt;ClickEvent&lt;/code&gt;. Si necesitás que ambas direcciones sean tipos distintos (&lt;code&gt;In = ChatMessage&lt;/code&gt;, &lt;code&gt;Out = ServerEvent&lt;/code&gt;), el workaround es hacer el tipo una suma (estilo &lt;code&gt;union&lt;/code&gt;) — declarar un tipo &lt;code&gt;Event&lt;/code&gt; más ancho con fields opcionales. La solución más limpia (&lt;code&gt;WsConn&amp;lt;In, Out&amp;gt;&lt;/code&gt; generic de dos tipos) está en el roadmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sin rooms / channels adentro de un endpoint&lt;/strong&gt;. &lt;code&gt;broadcast&lt;/code&gt; va a cada conexión sobre el endpoint. Si querés "broadcastear solo a users suscritos al proyecto 42", mantenés un &lt;code&gt;Map&amp;lt;Int, Vec&amp;lt;WsConn&amp;gt;&amp;gt;&lt;/code&gt; vos o partís en múltiples endpoints (uno por proyecto — funciona bien para counts bajos).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sin reconnect con replay de estado&lt;/strong&gt;. Si el dashboard se desconecta, al reconectar ve solo eventos nuevos — no hay "dame los últimos 30 segundos que me perdí". Construir eso necesita un event log (una tabla Postgres poleada, o Redis Streams). Afuera de la capa WebSocket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frames binarios&lt;/strong&gt; son soportados vía &lt;code&gt;WsConn&amp;lt;Bytes&amp;gt;&lt;/code&gt;, pero no hablé de ellos acá. Útiles para uploads de archivos o audio streaming; el AsyncAPI emite &lt;code&gt;format: binary&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Estos son los gaps honestos. El 90% de los casos de uso de WebSocket (updates en tiempo real, chat, dashboards en vivo, edición colaborativa multi-user para un solo documento) están cubiertos hoy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo esto compone con el resto de Fitz
&lt;/h2&gt;

&lt;p&gt;Podés mezclar WebSockets con todo lo demás:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt;: &lt;code&gt;@authenticated&lt;/code&gt;/&lt;code&gt;@admin&lt;/code&gt;/&lt;code&gt;@requires("role")&lt;/code&gt; funcionan sobre &lt;code&gt;@ws&lt;/code&gt;, evaluados antes del upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware&lt;/strong&gt;: middlewares corren antes del upgrade para cosas como rate limiting por IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORM&lt;/strong&gt;: el handler WebSocket puede tomar un &lt;code&gt;DbConn&lt;/code&gt; y queryear la base de datos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async&lt;/strong&gt;: &lt;code&gt;recv&lt;/code&gt;/&lt;code&gt;send&lt;/code&gt;/&lt;code&gt;broadcast&lt;/code&gt; son awaiteables; combinalos con llamadas HTTP o queries Postgres adentro del loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron / spawn&lt;/strong&gt;: jobs cron pueden &lt;code&gt;ws.broadcast("/topic", event)&lt;/code&gt; para pushear updates periódicos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry&lt;/strong&gt;: el chequeo de auth antes del upgrade emite un trace span; broadcasts subsecuentes también pueden trackearse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mismo lenguaje, mismos types, mismo binario. El WebSocket no es un mundo separado.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué necesitarías después para un producto real
&lt;/h2&gt;

&lt;p&gt;El dashboard de arriba alcanza para demo "clicks de URL shortener en tiempo real". Para un producto en producción extenderías con:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filtrado por usuario&lt;/strong&gt;: solo pushear clicks para URLs que el user creó. Trivial — chequeá &lt;code&gt;user.email == link.user_email&lt;/code&gt; antes de broadcastear, o partí en un endpoint por user con el email en el path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agregación&lt;/strong&gt;: no enviar cada click; enviar una rate de clicks por código por segundo. Mantené estado en el handler, enviá frames agregados cada 1 segundo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integración con framework de frontend&lt;/strong&gt;: tipos TypeScript desde el schema AsyncAPI. Corré &lt;code&gt;asyncapi generate&lt;/code&gt; una vez.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nada de esto cambia la estructura del código Fitz. Son todos "editá el call site del broadcast".&lt;/p&gt;

&lt;h2&gt;
  
  
  Probalo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# Reabrí la terminal, después:&lt;/span&gt;
git clone https://github.com/Thegreekman76/fitz.git
&lt;span class="nb"&gt;cd &lt;/span&gt;fitz/boilerplates/api-websocket

&lt;span class="c"&gt;# Leé el README, corré con docker compose o `fitz dev`&lt;/span&gt;
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para VSCode (recomendado — hover sobre &lt;code&gt;WsConn&amp;lt;T&amp;gt;&lt;/code&gt;, autocomplete en &lt;code&gt;conn.send&lt;/code&gt;/&lt;code&gt;recv&lt;/code&gt;/&lt;code&gt;broadcast&lt;/code&gt;): bajá el &lt;code&gt;fitz-lang-&amp;lt;plataforma&amp;gt;.vsix&lt;/code&gt; desde la &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;página de releases&lt;/a&gt; y &lt;code&gt;code --install-extension fitz-lang-&amp;lt;plataforma&amp;gt;.vsix --force&lt;/code&gt;. El Language Server viene incluido.&lt;/p&gt;

&lt;p&gt;El boilerplate &lt;code&gt;api-websocket&lt;/code&gt; es un servidor de chat tipado. Más o menos el mismo shape que el dashboard de arriba — &lt;code&gt;loop { recv; broadcast }&lt;/code&gt; con auth.&lt;/p&gt;

&lt;p&gt;Para el shortener completo + dashboard en vivo, el boilerplate &lt;code&gt;api-orm-full&lt;/code&gt; tiene la cosa entera ensamblada (rutas HTTP + ORM + dashboard WebSocket + cron job + auth JWT) en ~250 líneas total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Boilerplate &lt;code&gt;api-websocket&lt;/code&gt;&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-websocket" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/tree/main/boilerplates/api-websocket&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Boilerplate &lt;code&gt;api-orm-full&lt;/code&gt;&lt;/strong&gt; (la cosa entera): &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs y curso&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Capítulo de la guía sobre WebSockets&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/#29-websockets&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Si construís algo con esto, escribime. Lo quiero ver.&lt;/p&gt;

&lt;p&gt;Hasta la próxima.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>websockets</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Fitz CLI builder: como typer, pero en el lenguaje</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Sat, 13 Jun 2026 10:35:11 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/fitz-cli-builder-como-typer-pero-en-el-lenguaje-2ab0</link>
      <guid>https://dev.to/martin_palopoli/fitz-cli-builder-como-typer-pero-en-el-lenguaje-2ab0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Construí herramientas CLI nativas en Fitz con &lt;code&gt;@command&lt;/code&gt;, sin librería que instalar. Help auto-generado, flags con type coercion, positional args por convención, binario nativo a la salida. El mismo lenguaje que mueve servicios HTTP construye tus scripts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  ¿Por qué un CLI builder en el lenguaje?
&lt;/h2&gt;

&lt;p&gt;La mayoría del trabajo de CLI en Python vive en &lt;code&gt;typer&lt;/code&gt;, &lt;code&gt;click&lt;/code&gt; o &lt;code&gt;argparse&lt;/code&gt;. Todas son librerías decentes; &lt;code&gt;typer&lt;/code&gt; en particular es delicioso. La respuesta de Rust es &lt;code&gt;clap&lt;/code&gt;. La de Go es &lt;code&gt;cobra&lt;/code&gt; o &lt;code&gt;urfave/cli&lt;/code&gt;. La de Node es &lt;code&gt;commander.js&lt;/code&gt; o &lt;code&gt;yargs&lt;/code&gt;. Cada lenguaje tiene una librería CLI. &lt;strong&gt;Cada una de ellas es una librería&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Una librería está bien hasta que te acordás que:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Las convenciones de la librería se imponen al resto de tu código (decoradores, factory objects, DSLs de builder).&lt;/li&gt;
&lt;li&gt;El formato del help text es decisión de la librería, no del lenguaje.&lt;/li&gt;
&lt;li&gt;Distribuir el resultado necesita un packager (&lt;code&gt;pyinstaller&lt;/code&gt;, &lt;code&gt;pyox&lt;/code&gt;, Docker, etc.) arriba.&lt;/li&gt;
&lt;li&gt;El comportamiento cross-platform depende de la cobertura de la librería, no del lenguaje.&lt;/li&gt;
&lt;li&gt;Agregar un &lt;code&gt;--flag&lt;/code&gt; es cambiar la signature de una función &lt;strong&gt;más&lt;/strong&gt; una llamada a decorador &lt;strong&gt;más&lt;/strong&gt; quizás un objeto de config.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;¿Qué pasaría si el mismo compilador que produce tu server HTTP también produce tus CLI tools, con el mismo type checker, el mismo async/await, el mismo &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt; para errores, el mismo binario nativo como output?&lt;/p&gt;

&lt;p&gt;Ese es el CLI builder de Fitz.&lt;/p&gt;

&lt;h2&gt;
  
  
  El hello-world completo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// main.fitz
@command("greet", desc="Saludar a una persona")
fn greet(name: Str, loud: Bool = false, count: Int = 1) -&amp;gt; Int {
    let n = count
    while n &amp;gt; 0 {
        if loud {
            print("HOLA, {name}!")
        } else {
            print("hola, {name}")
        }
        n = n - 1
    }
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Corrélo local durante el desarrollo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz run main.fitz greet Ada
hola, Ada

&lt;span class="nv"&gt;$ &lt;/span&gt;fitz run main.fitz greet Ada &lt;span class="nt"&gt;--loud&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; 3
HOLA, Ada!
HOLA, Ada!
HOLA, Ada!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compilá a un binario self-contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz build
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; ./greeter
&lt;span class="nt"&gt;-rwxr-xr-x&lt;/span&gt;  1  user  user   5.2M  Jun  5 14:00 ./greeter

&lt;span class="nv"&gt;$ &lt;/span&gt;./greeter greet Ada &lt;span class="nt"&gt;--loud&lt;/span&gt;
HOLA, Ada!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Binario nativo de 5 MB, estáticamente linkeado excepto libc. Sin &lt;code&gt;pyinstaller&lt;/code&gt;. Sin &lt;code&gt;pyz&lt;/code&gt;. Sin intérprete Python en la máquina destino.&lt;/p&gt;

&lt;h2&gt;
  
  
  La convención: sin decoradores &lt;code&gt;@arg&lt;/code&gt;/&lt;code&gt;@flag&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;typer&lt;/code&gt; y &lt;code&gt;click&lt;/code&gt; te piden que marques cada parámetro con un decorador que diga "esto es positional argument" o "esto es flag". Fitz usa una convención que cubre el mismo terreno sin la verbosidad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Param sin default&lt;/strong&gt; → positional argument requerido (&lt;code&gt;mybin greet &amp;lt;name&amp;gt;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Param con default&lt;/strong&gt; → flag (&lt;code&gt;--name &amp;lt;value&amp;gt;&lt;/code&gt;, o &lt;code&gt;--loud&lt;/code&gt; para flags &lt;code&gt;Bool&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Bool = false&lt;/code&gt;&lt;/strong&gt; → flag bool (&lt;code&gt;--loud&lt;/code&gt; lo activa, sin valor).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Int = N&lt;/code&gt;, &lt;code&gt;Float = X&lt;/code&gt;, &lt;code&gt;Str = "..."&lt;/code&gt;&lt;/strong&gt; → flag con valor (&lt;code&gt;--count 3&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La signature &lt;code&gt;fn greet(name: Str, loud: Bool = false, count: Int = 1) -&amp;gt; Int&lt;/code&gt; le dice al compilador exactamente:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Param&lt;/th&gt;
&lt;th&gt;Convención&lt;/th&gt;
&lt;th&gt;CLI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name: Str&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sin default → positional&lt;/td&gt;
&lt;td&gt;&lt;code&gt;greet &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loud: Bool = false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bool con default → flag&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--loud&lt;/code&gt; (presencia = &lt;code&gt;true&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;count: Int = 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int con default → flag con valor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--count 3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El trade-off: no podés tener positional optional args. Si querés uno, declaralo &lt;code&gt;Str?&lt;/code&gt; (nullable) y hacé &lt;code&gt;match&lt;/code&gt; en el body — el shape queda bien, el body maneja el caso missing explícito.&lt;/p&gt;

&lt;p&gt;Conviví con este trade-off una semana antes de empezar a escribir CLIs en Fitz en lugar de &lt;code&gt;typer&lt;/code&gt;, y no volví. La signature &lt;strong&gt;es&lt;/strong&gt; la definición del CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  El help es auto-generado
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin &lt;span class="nt"&gt;--help&lt;/span&gt;
USAGE:
    mybin &amp;lt;&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;ARGS] &lt;span class="o"&gt;[&lt;/span&gt;OPTIONS]

COMMANDS:
    greet    Saludar a una persona

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin greet &lt;span class="nt"&gt;--help&lt;/span&gt;
USAGE:
    mybin greet &amp;lt;name&amp;gt; &lt;span class="o"&gt;[&lt;/span&gt;OPTIONS]

ARGUMENTS:
    &amp;lt;name&amp;gt;     &lt;span class="o"&gt;(&lt;/span&gt;requerido&lt;span class="o"&gt;)&lt;/span&gt;

OPTIONS:
    &lt;span class="nt"&gt;--loud&lt;/span&gt;, &lt;span class="nt"&gt;-l&lt;/span&gt;           default: &lt;span class="nb"&gt;false&lt;/span&gt;
    &lt;span class="nt"&gt;--count&lt;/span&gt;, &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt;count&amp;gt;  default: 1
    &lt;span class="nt"&gt;-h&lt;/span&gt;, &lt;span class="nt"&gt;--help&lt;/span&gt;           mostrar este &lt;span class="nb"&gt;help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short flags (&lt;code&gt;-l&lt;/code&gt;, &lt;code&gt;-c&lt;/code&gt;) se auto-derivan de la primera letra de la long flag. Si dos flags colisionarían en la misma letra, el compilador te avisa en build time (no en runtime, no cuando el user lo descubre):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ @command("greet") short flag conflict: `--loud` y `--limit`
  comparten primera letra `-l`. Renombrá una, u opt-out una con
  `@flag(short=null)`.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El formateo del output sigue convenciones de &lt;code&gt;clap&lt;/code&gt; porque &lt;code&gt;clap&lt;/code&gt; ya optimizó esto; no tiene sentido reinventarlo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-command dispatch
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("greet", desc="Saludar a una persona")
fn greet(name: Str) -&amp;gt; Int { print("hola, {name}"); return 0 }

@command("add", desc="Sumar dos números")
fn add(a: Int, b: Int) -&amp;gt; Int { print("{a + b}"); return 0 }

@command("status", desc="Chequear estado del servicio")
async fn status(url: Str = "http://localhost:8080") -&amp;gt; Int {
    // ... llamada HTTP a la URL ...
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin &lt;span class="nt"&gt;--help&lt;/span&gt;
COMMANDS:
    greet     Saludar a una persona
    add       Sumar dos números
    status    Chequear estado del servicio

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin add 21 21
42

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin status &lt;span class="nt"&gt;--url&lt;/span&gt; http://prod.example.com
✓ healthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El dispatch se genera en build time. No hay tabla de lookup de comandos en runtime que mantener.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async nativo, porque por qué no
&lt;/h2&gt;

&lt;p&gt;El comando &lt;code&gt;status&lt;/code&gt; de arriba es &lt;code&gt;async&lt;/code&gt;. El compilador lo detecta y envuelve el dispatch en &lt;code&gt;#[tokio::main]&lt;/code&gt; automáticamente:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("fetch", desc="Traer una URL e imprimir el body")
async fn fetch(url: Str) -&amp;gt; Int {
    // Asumí que `http.get` fuera built-in (todavía no lo es; esto es ilustrativo).
    let body = http.get(url).await?
    print(body)
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El mismo async/await que usás en handlers HTTP funciona en CLI commands. Sin boilerplate de &lt;code&gt;asyncio.run()&lt;/code&gt;. Sin "este comando es async, ese otro no, por favor no mezcles". Solo &lt;code&gt;async fn&lt;/code&gt; cuando lo querés.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit codes
&lt;/h2&gt;

&lt;p&gt;Las CLI tools viven y mueren por exit codes. La convención es POSIX:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; — éxito.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1+&lt;/code&gt; — retornado por el handler explícitamente.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; — error de parsing del CLI (flag desconocida, type incorrecto, positional faltante).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("validate", desc="Chequear un archivo")
fn validate(path: Str) -&amp;gt; Int {
    let contents = read_file(path)
    if (contents.starts_with("FAIL")) {
        print("validación falló")
        return 1   // distinto de error interno
    }
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin validate ok.txt
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin validate fail.txt
validación falló
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
1

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin validate
✗ greet: missing positional argument &lt;span class="sb"&gt;`&lt;/span&gt;&amp;lt;path&amp;gt;&lt;span class="sb"&gt;`&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El parser CLI maneja los errores de parsing. Tu código maneja los errores de negocio. Limpio.&lt;/p&gt;

&lt;h2&gt;
  
  
  El poder completo del lenguaje
&lt;/h2&gt;

&lt;p&gt;Acá es donde el approach de Fitz paga en una forma que &lt;code&gt;typer&lt;/code&gt; no puede: tenés &lt;strong&gt;el lenguaje entero&lt;/strong&gt; a tu disposición en CLI tools, no solo funciones y prints.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI tools con el ORM
&lt;/h3&gt;

&lt;p&gt;¿Necesitás un CLI de admin de DB? Es el mismo ORM que el servicio HTTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("list-users", desc="Imprimir todos los users")
async fn list_users(db: DbConn) -&amp;gt; Int {
    let users = User.all(db).await
    for u in users {
        print("#{u.id}\t{u.email}\t{u.role}")
    }
    return 0
}

@command("delete-user", desc="Borrar un user por email")
async fn delete_user(db: DbConn, email: Str, force: Bool = false) -&amp;gt; Int {
    if not force {
        print("Usá --force para borrar de verdad")
        return 1
    }
    User.where(fn(u) =&amp;gt; u.email == email).delete(db).await
    print("borrado")
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La inyección de &lt;code&gt;DbConn&lt;/code&gt; funciona igual que en handlers HTTP. Closure-to-SQL en &lt;code&gt;.where(...)&lt;/code&gt; funciona. Eager loading funciona. Las migraciones de &lt;code&gt;fitz db diff/migrate&lt;/code&gt; funcionan. El CLI tool tiene el mismo acceso a tu capa de datos que el API server, con la misma seguridad de tipos.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI con llamadas HTTP
&lt;/h3&gt;

&lt;p&gt;¿Querés un CLI que hable con tu propio API? Mismo lenguaje, sin SDK separado:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("ping-prod", desc="Pegarle al /healthz de prod")
async fn ping_prod() -&amp;gt; Int {
    let url = env_or("PROD_URL", "https://api.example.com")
    // ... usá la stdlib o `from python import requests` etc ...
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CLI como wrapper de herramientas Python
&lt;/h3&gt;

&lt;p&gt;¿Necesitás envolver una herramienta Python pero con mejor UX? El interop Python está en Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from python import subprocess

@command("backup-db", desc="Correr pg_dump y subir a S3")
async fn backup_db(db_url: Str, bucket: Str) -&amp;gt; Int {
    let dump_cmd = "pg_dump {db_url} &amp;gt; backup.sql"
    subprocess.run(dump_cmd, shell=true).await?
    // ... upload a S3 ...
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El CLI está en Fitz. La llamada a pg_dump es shell. El upload a S3 podría ser Python con &lt;code&gt;boto3&lt;/code&gt; (&lt;code&gt;from python import boto3&lt;/code&gt;). Un binario, tres ecosistemas compuestos.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI con config + secrets
&lt;/h3&gt;

&lt;p&gt;El mismo &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; y &lt;code&gt;config(...)&lt;/code&gt; de la Parte 3 funcionan en CLI tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("deploy", desc="Empujar un release")
async fn deploy(env: Str = "staging") -&amp;gt; Int {
    let api_key: Secret&amp;lt;Str&amp;gt; = secret("DEPLOY_API_KEY")
    let endpoint: Str = config("DEPLOY_ENDPOINT", "https://deploy.internal")
    // ... usá api_key.expose() en el request ...
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api_key&lt;/code&gt; nunca leakea a logs incluso si el path de error del comando deploy imprime el config map. Las mismas reglas de redacción de los servicios HTTP aplican acá.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boilerplate &lt;code&gt;cli-tool&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;El repo tiene un boilerplate para proyectos CLI en &lt;code&gt;boilerplates/cli-tool/&lt;/code&gt;. Trae tres comandos como starter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;report&lt;/code&gt; — genera un sumario de datos.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;count&lt;/code&gt; — cuenta items.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;regions&lt;/code&gt; — lista categorías.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz new my-cli &lt;span class="nt"&gt;--template&lt;/span&gt; cli-tool
&lt;span class="nb"&gt;cd &lt;/span&gt;my-cli
fitz dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hot reload también funciona en CLI mode — &lt;code&gt;fitz dev&lt;/code&gt; watchea el source, mata y respawnea cuando guardás.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que no está en la caja
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompts interactivos&lt;/strong&gt; (pensá &lt;code&gt;inquirer&lt;/code&gt; / &lt;code&gt;click.prompt&lt;/code&gt;). Fitz todavía no tiene readers de stdin estilo &lt;code&gt;read_line&lt;/code&gt; en el core. Por ahora, aceptá una flag y alimentala vía env o stdin piped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TUI&lt;/strong&gt; (terminal UI con control de cursor). Fuera de scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generación de shell completion&lt;/strong&gt; (&lt;code&gt;bash&lt;/code&gt;/&lt;code&gt;zsh&lt;/code&gt;/&lt;code&gt;fish&lt;/code&gt;). No se genera automático todavía. El approach del archivo &lt;code&gt;_complete&lt;/code&gt; escrito a mano sigue funcionando.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--no-foo&lt;/code&gt; para flags &lt;code&gt;Bool = true&lt;/code&gt;&lt;/strong&gt;. Bools default a &lt;code&gt;false&lt;/code&gt;; si querés una flag que default a true y vos opt-out, la convención es invertir el booleano en tu lógica de negocio por ahora. Trackeada como deuda menor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si algo de esto te bloquea, abrí un issue. O son chicos para agregar o ya están en progreso.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué importa
&lt;/h2&gt;

&lt;p&gt;Cada developer que construye servicios web también termina escribiendo CLI tools. Migraciones para correr, imports ad-hoc, smoke tests, scripts de deploy, acciones admin, exports de data. La elección hoy en la mayoría de los lenguajes es:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mismo lenguaje que el servicio, con una librería CLI separada&lt;/strong&gt; (Python: &lt;code&gt;typer&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bash / shell script&lt;/strong&gt; que llama a tu servicio vía HTTP (perdés toda la seguridad de tipos).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un lenguaje separado para ops&lt;/strong&gt; (binarios Go al lado de un servicio Python — está bien, pero dos lenguajes para mantener).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fitz propone una cuarta: mismo compilador, mismo type checker, mismo ORM, mismo async, &lt;strong&gt;mismo formato de binario&lt;/strong&gt;. Tus CLI tools viven en el mismo repo, comparten el mismo &lt;code&gt;type User&lt;/code&gt;, le pegan al mismo Postgres con la misma lógica de conexión.&lt;/p&gt;

&lt;p&gt;Podés escribir &lt;code&gt;mybin migrate&lt;/code&gt; y &lt;code&gt;mybin reset-test-db&lt;/code&gt; en el mismo archivo &lt;code&gt;.fitz&lt;/code&gt; que tus handlers &lt;code&gt;@get("/users")&lt;/code&gt; si querés. O dividir en comandos separados por binario. La elección es tuya, no de la librería.&lt;/p&gt;

&lt;p&gt;Probalo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# Reabrí la terminal, después:&lt;/span&gt;
fitz new micli &lt;span class="nt"&gt;--template&lt;/span&gt; cli-tool
&lt;span class="nb"&gt;cd &lt;/span&gt;micli
fitz run main.fitz greet &lt;span class="nt"&gt;--help&lt;/span&gt;
fitz build
./micli greet Ada &lt;span class="nt"&gt;--loud&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para VSCode (recomendado para editar — hover con tipos, autocomplete, signature help): bajá el &lt;code&gt;fitz-lang-&amp;lt;plataforma&amp;gt;.vsix&lt;/code&gt; desde la &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;página de releases&lt;/a&gt; y &lt;code&gt;code --install-extension fitz-lang-&amp;lt;plataforma&amp;gt;.vsix --force&lt;/code&gt;. El Language Server viene incluido.&lt;/p&gt;

&lt;p&gt;Vas a tener un binario nativo self-contained en tu &lt;code&gt;pwd&lt;/code&gt; en menos de cinco minutos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Boilerplate &lt;code&gt;cli-tool&lt;/code&gt;&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/cli-tool" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/tree/main/boilerplates/cli-tool&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs y curso&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Capítulo de la guía sobre CLI&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/#33-cli&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hasta la próxima.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>opensource</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Fitz CLI builder: like typer, but in the language</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Sat, 13 Jun 2026 10:34:52 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/fitz-cli-builder-like-typer-but-in-the-language-32kg</link>
      <guid>https://dev.to/martin_palopoli/fitz-cli-builder-like-typer-but-in-the-language-32kg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Build native CLI tools in Fitz with &lt;code&gt;@command&lt;/code&gt;, no library to install. Help auto-generated, type-coerced flags, positional args by convention, native binary out the door. The same language that powers HTTP services builds your scripts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a CLI builder in the language?
&lt;/h2&gt;

&lt;p&gt;Most CLI work in Python lives in &lt;code&gt;typer&lt;/code&gt;, &lt;code&gt;click&lt;/code&gt;, or &lt;code&gt;argparse&lt;/code&gt;. They're all decent libraries; &lt;code&gt;typer&lt;/code&gt; in particular is delightful. The Rust answer is &lt;code&gt;clap&lt;/code&gt;. The Go answer is &lt;code&gt;cobra&lt;/code&gt; or &lt;code&gt;urfave/cli&lt;/code&gt;. The Node answer is &lt;code&gt;commander.js&lt;/code&gt; or &lt;code&gt;yargs&lt;/code&gt;. Every language has a CLI library. &lt;strong&gt;Every one of them is a library&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A library is fine until you remember that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The library's conventions are imposed on the rest of your code (decorators, factory objects, builder DSLs).&lt;/li&gt;
&lt;li&gt;The help text formatting is the library's decision, not the language's.&lt;/li&gt;
&lt;li&gt;Distributing the result needs a packager (&lt;code&gt;pyinstaller&lt;/code&gt;, &lt;code&gt;pyox&lt;/code&gt;, Docker, etc.) on top.&lt;/li&gt;
&lt;li&gt;Cross-platform behavior depends on the library's coverage, not the language's.&lt;/li&gt;
&lt;li&gt;Adding a &lt;code&gt;--flag&lt;/code&gt; is changing a function signature &lt;strong&gt;plus&lt;/strong&gt; a decorator call &lt;strong&gt;plus&lt;/strong&gt; maybe a config object.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What if the same compiler that produces your HTTP server also produces your CLI tools, with the same type checker, the same async/await, the same &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt; for errors, the same native binary as output?&lt;/p&gt;

&lt;p&gt;That's the Fitz CLI builder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full hello-world
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// main.fitz
@command("greet", desc="Greet a person")
fn greet(name: Str, loud: Bool = false, count: Int = 1) -&amp;gt; Int {
    let n = count
    while n &amp;gt; 0 {
        if loud {
            print("HELLO, {name}!")
        } else {
            print("hello, {name}")
        }
        n = n - 1
    }
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it locally during development:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz run main.fitz greet Ada
hello, Ada

&lt;span class="nv"&gt;$ &lt;/span&gt;fitz run main.fitz greet Ada &lt;span class="nt"&gt;--loud&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; 3
HELLO, Ada!
HELLO, Ada!
HELLO, Ada!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compile to a self-contained binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz build
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; ./greeter
&lt;span class="nt"&gt;-rwxr-xr-x&lt;/span&gt;  1  user  user   5.2M  Jun  5 14:00 ./greeter

&lt;span class="nv"&gt;$ &lt;/span&gt;./greeter greet Ada &lt;span class="nt"&gt;--loud&lt;/span&gt;
HELLO, Ada!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5 MB native binary, statically linked except libc. No &lt;code&gt;pyinstaller&lt;/code&gt;. No &lt;code&gt;pyz&lt;/code&gt;. No Python interpreter on the target machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The convention: no &lt;code&gt;@arg&lt;/code&gt;/&lt;code&gt;@flag&lt;/code&gt; decorators
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;typer&lt;/code&gt; and &lt;code&gt;click&lt;/code&gt; ask you to mark every parameter with a decorator that says "this is a positional argument" or "this is a flag". Fitz uses a convention that covers the same ground without the verbosity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Param without a default&lt;/strong&gt; → positional argument required (&lt;code&gt;mybin greet &amp;lt;name&amp;gt;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Param with a default&lt;/strong&gt; → flag (&lt;code&gt;--name &amp;lt;value&amp;gt;&lt;/code&gt;, or &lt;code&gt;--loud&lt;/code&gt; for &lt;code&gt;Bool&lt;/code&gt; flags).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Bool = false&lt;/code&gt;&lt;/strong&gt; → flag bool (&lt;code&gt;--loud&lt;/code&gt; enables, no value).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Int = N&lt;/code&gt;, &lt;code&gt;Float = X&lt;/code&gt;, &lt;code&gt;Str = "..."&lt;/code&gt;&lt;/strong&gt; → flag with value (&lt;code&gt;--count 3&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signature &lt;code&gt;fn greet(name: Str, loud: Bool = false, count: Int = 1) -&amp;gt; Int&lt;/code&gt; tells the compiler exactly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Param&lt;/th&gt;
&lt;th&gt;Convention&lt;/th&gt;
&lt;th&gt;CLI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name: Str&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no default → positional&lt;/td&gt;
&lt;td&gt;&lt;code&gt;greet &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loud: Bool = false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bool with default → flag&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--loud&lt;/code&gt; (presence = &lt;code&gt;true&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;count: Int = 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;int with default → flag with value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--count 3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trade-off: you can't have positional optional args. If you want one, declare it &lt;code&gt;Str?&lt;/code&gt; (nullable) and &lt;code&gt;match&lt;/code&gt; on it in the body — the shape stays right, the body handles the missing case explicitly.&lt;/p&gt;

&lt;p&gt;I lived with this trade-off for a week before I started writing CLIs in Fitz instead of &lt;code&gt;typer&lt;/code&gt;, and I haven't gone back. The signature &lt;strong&gt;is&lt;/strong&gt; the CLI definition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Help is auto-generated
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin &lt;span class="nt"&gt;--help&lt;/span&gt;
USAGE:
    mybin &amp;lt;&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;ARGS] &lt;span class="o"&gt;[&lt;/span&gt;OPTIONS]

COMMANDS:
    greet    Greet a person

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin greet &lt;span class="nt"&gt;--help&lt;/span&gt;
USAGE:
    mybin greet &amp;lt;name&amp;gt; &lt;span class="o"&gt;[&lt;/span&gt;OPTIONS]

ARGUMENTS:
    &amp;lt;name&amp;gt;     &lt;span class="o"&gt;(&lt;/span&gt;required&lt;span class="o"&gt;)&lt;/span&gt;

OPTIONS:
    &lt;span class="nt"&gt;--loud&lt;/span&gt;, &lt;span class="nt"&gt;-l&lt;/span&gt;           default: &lt;span class="nb"&gt;false&lt;/span&gt;
    &lt;span class="nt"&gt;--count&lt;/span&gt;, &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt;count&amp;gt;  default: 1
    &lt;span class="nt"&gt;-h&lt;/span&gt;, &lt;span class="nt"&gt;--help&lt;/span&gt;           show this &lt;span class="nb"&gt;help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short flags (&lt;code&gt;-l&lt;/code&gt;, &lt;code&gt;-c&lt;/code&gt;) are auto-derived from the long flag's first letter. If two flags would collide on the same letter, the compiler tells you at build time (not at runtime, not when the user discovers it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ @command("greet") short flag conflict: `--loud` and `--limit`
  share first letter `-l`. Rename one, or opt out one with
  `@flag(short=null)`.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output formatting follows &lt;code&gt;clap&lt;/code&gt; conventions because &lt;code&gt;clap&lt;/code&gt; already optimized this; no point in reinventing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-command dispatch
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("greet", desc="Greet a person")
fn greet(name: Str) -&amp;gt; Int { print("hello, {name}"); return 0 }

@command("add", desc="Sum two numbers")
fn add(a: Int, b: Int) -&amp;gt; Int { print("{a + b}"); return 0 }

@command("status", desc="Check service status")
async fn status(url: Str = "http://localhost:8080") -&amp;gt; Int {
    // ... HTTP call to the URL ...
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin &lt;span class="nt"&gt;--help&lt;/span&gt;
COMMANDS:
    greet     Greet a person
    add       Sum two numbers
    status    Check service status

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin add 21 21
42

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin status &lt;span class="nt"&gt;--url&lt;/span&gt; http://prod.example.com
✓ healthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dispatch is generated at build time. There's no runtime command lookup table to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native async, because why not
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;status&lt;/code&gt; command above is &lt;code&gt;async&lt;/code&gt;. The compiler detects this and wraps the dispatch in &lt;code&gt;#[tokio::main]&lt;/code&gt; automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("fetch", desc="Fetch a URL and print the body")
async fn fetch(url: Str) -&amp;gt; Int {
    // Suppose `http.get` were a built-in (it's not yet; this is illustrative).
    let body = http.get(url).await?
    print(body)
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same async/await you use in HTTP handlers works in CLI commands. No &lt;code&gt;asyncio.run()&lt;/code&gt; boilerplate. No "this command is async, that one isn't, please don't mix". Just &lt;code&gt;async fn&lt;/code&gt; when you want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit codes
&lt;/h2&gt;

&lt;p&gt;CLI tools live and die by exit codes. The convention is POSIX:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; — success.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1+&lt;/code&gt; — returned by the handler explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; — CLI parsing error (unknown flag, bad type, missing positional).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("validate", desc="Check a file")
fn validate(path: Str) -&amp;gt; Int {
    let contents = read_file(path)
    if (contents.starts_with("FAIL")) {
        print("validation failed")
        return 1   // distinct from internal error
    }
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin validate ok.txt
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin validate fail.txt
validation failed
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
1

&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin validate
✗ greet: missing positional argument &lt;span class="sb"&gt;`&lt;/span&gt;&amp;lt;path&amp;gt;&lt;span class="sb"&gt;`&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI parser handles the parsing errors. Your code handles the business errors. Clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full power of the language
&lt;/h2&gt;

&lt;p&gt;This is where the Fitz approach pays off in a way that &lt;code&gt;typer&lt;/code&gt; can't: you have &lt;strong&gt;the entire language&lt;/strong&gt; at your disposal in CLI tools, not just functions and prints.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI tools with the ORM
&lt;/h3&gt;

&lt;p&gt;You need a database admin CLI? It's the same ORM as the HTTP service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("list-users", desc="Print all users")
async fn list_users(db: DbConn) -&amp;gt; Int {
    let users = User.all(db).await
    for u in users {
        print("#{u.id}\t{u.email}\t{u.role}")
    }
    return 0
}

@command("delete-user", desc="Delete a user by email")
async fn delete_user(db: DbConn, email: Str, force: Bool = false) -&amp;gt; Int {
    if not force {
        print("Use --force to actually delete")
        return 1
    }
    User.where(fn(u) =&amp;gt; u.email == email).delete(db).await
    print("deleted")
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DbConn&lt;/code&gt; injection works the same way it does in HTTP handlers. Closure-to-SQL in &lt;code&gt;.where(...)&lt;/code&gt; works. Eager loading works. The migrations from &lt;code&gt;fitz db diff/migrate&lt;/code&gt; work. The CLI tool has the same access to your data layer as the API server, with the same type safety.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI with HTTP calls
&lt;/h3&gt;

&lt;p&gt;You want a CLI that talks to your own API? Same language, no separate SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("ping-prod", desc="Hit the prod /healthz")
async fn ping_prod() -&amp;gt; Int {
    let url = env_or("PROD_URL", "https://api.example.com")
    // ... use the standard library or `from python import requests` etc ...
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CLI as a wrapper around Python tools
&lt;/h3&gt;

&lt;p&gt;Need to wrap a Python tool but with better UX? Python interop is in Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from python import subprocess

@command("backup-db", desc="Run pg_dump and upload to S3")
async fn backup_db(db_url: Str, bucket: Str) -&amp;gt; Int {
    let dump_cmd = "pg_dump {db_url} &amp;gt; backup.sql"
    subprocess.run(dump_cmd, shell=true).await?
    // ... upload to S3 ...
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI is in Fitz. The pg_dump call is shell. The S3 upload could be Python with &lt;code&gt;boto3&lt;/code&gt; (&lt;code&gt;from python import boto3&lt;/code&gt;). One binary, three ecosystems composed.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI with config + secrets
&lt;/h3&gt;

&lt;p&gt;The same &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; and &lt;code&gt;config(...)&lt;/code&gt; from Part 3 work in CLI tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@command("deploy", desc="Push a release")
async fn deploy(env: Str = "staging") -&amp;gt; Int {
    let api_key: Secret&amp;lt;Str&amp;gt; = secret("DEPLOY_API_KEY")
    let endpoint: Str = config("DEPLOY_ENDPOINT", "https://deploy.internal")
    // ... use api_key.expose() in the request ...
    return 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api_key&lt;/code&gt; never leaks to logs even if the deploy command's error path prints the config map. The same redaction rules from HTTP services apply here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boilerplate &lt;code&gt;cli-tool&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The repo has a boilerplate for CLI projects at &lt;code&gt;boilerplates/cli-tool/&lt;/code&gt;. It ships three commands as a starter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;report&lt;/code&gt; — generates a summary from data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;count&lt;/code&gt; — counts items.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;regions&lt;/code&gt; — lists categories.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz new my-cli &lt;span class="nt"&gt;--template&lt;/span&gt; cli-tool
&lt;span class="nb"&gt;cd &lt;/span&gt;my-cli
fitz dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hot reload works in CLI mode too — &lt;code&gt;fitz dev&lt;/code&gt; watches the source, kills and respawns when you save.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not in the box
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interactive prompts&lt;/strong&gt; (think &lt;code&gt;inquirer&lt;/code&gt; / &lt;code&gt;click.prompt&lt;/code&gt;). Fitz doesn't have &lt;code&gt;read_line&lt;/code&gt; style stdin readers in the core yet. For now, accept a flag and feed it via env or piped stdin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TUI&lt;/strong&gt; (terminal UI with cursor control). Not in scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shell completion generation&lt;/strong&gt; (&lt;code&gt;bash&lt;/code&gt;/&lt;code&gt;zsh&lt;/code&gt;/&lt;code&gt;fish&lt;/code&gt;). Not generated automatically yet. The handwritten &lt;code&gt;_complete&lt;/code&gt; file approach still works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--no-foo&lt;/code&gt; for &lt;code&gt;Bool = true&lt;/code&gt; flags&lt;/strong&gt;. Bools default to &lt;code&gt;false&lt;/code&gt;; if you want a flag that defaults to true and you opt out, the convention is to invert the boolean in your business logic for now. Tracked as a minor debt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these block you, file an issue. They're either small enough to add or already in progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Every developer who builds web services also ends up writing CLI tools. Migrations to run, ad-hoc imports, smoke tests, deploy scripts, admin actions, data exports. The choice today in most languages is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same language as the service, with a separate CLI library&lt;/strong&gt; (Python: &lt;code&gt;typer&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bash / shell script&lt;/strong&gt; that calls your service via HTTP (lose all type safety).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate language for ops&lt;/strong&gt; (Go binaries next to a Python service — fine, but two languages to maintain).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fitz proposes a fourth: same compiler, same type checker, same ORM, same async, &lt;strong&gt;same binary format&lt;/strong&gt;. Your CLI tools live in the same repo, share the same &lt;code&gt;type User&lt;/code&gt;, hit the same Postgres with the same connection logic.&lt;/p&gt;

&lt;p&gt;You can write &lt;code&gt;mybin migrate&lt;/code&gt; and &lt;code&gt;mybin reset-test-db&lt;/code&gt; in the same &lt;code&gt;.fitz&lt;/code&gt; file as your &lt;code&gt;@get("/users")&lt;/code&gt; handlers if you want. Or split into separate commands per binary. The choice is yours, not the library's.&lt;/p&gt;

&lt;p&gt;Try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# Reopen the terminal, then:&lt;/span&gt;
fitz new mycli &lt;span class="nt"&gt;--template&lt;/span&gt; cli-tool
&lt;span class="nb"&gt;cd &lt;/span&gt;mycli
fitz run main.fitz greet &lt;span class="nt"&gt;--help&lt;/span&gt;
fitz build
./mycli greet Ada &lt;span class="nt"&gt;--loud&lt;/span&gt; &lt;span class="nt"&gt;--count&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For VSCode (recommended for editing — hover with types, autocomplete, signature help): grab the &lt;code&gt;fitz-lang-&amp;lt;platform&amp;gt;.vsix&lt;/code&gt; from the &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; and &lt;code&gt;code --install-extension fitz-lang-&amp;lt;platform&amp;gt;.vsix --force&lt;/code&gt;. The Language Server is bundled.&lt;/p&gt;

&lt;p&gt;You'll have a self-contained native binary in your &lt;code&gt;pwd&lt;/code&gt; in under five minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;code&gt;cli-tool&lt;/code&gt; boilerplate&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/tree/main/boilerplates/cli-tool" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/tree/main/boilerplates/cli-tool&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs and course&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Guide chapter on CLI&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/#33-cli&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Until the next one.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>opensource</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Del repo a producción con un solo comando: cómo Fitz hace del deployment una feature del lenguaje</title>
      <dc:creator>Martin Palopoli</dc:creator>
      <pubDate>Thu, 11 Jun 2026 13:57:00 +0000</pubDate>
      <link>https://dev.to/martin_palopoli/del-repo-a-produccion-con-un-solo-comando-como-fitz-hace-del-deployment-una-feature-del-lenguaje-264c</link>
      <guid>https://dev.to/martin_palopoli/del-repo-a-produccion-con-un-solo-comando-como-fitz-hace-del-deployment-una-feature-del-lenguaje-264c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Un recorrido por la historia de deployment de Fitz — healthchecks, secrets como tipos opacos, observability con OpenTelemetry, Dockerfiles autogenerados y &lt;code&gt;fitz deploy&lt;/code&gt;. Production-ready no es una checklist, es sintaxis.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  La historia de deployment que la mayoría de los lenguajes no cuenta
&lt;/h2&gt;

&lt;p&gt;El primer 80% de un servicio es divertido: rutas, types, lógica de negocio, tests. El último 20% es la parte que efectivamente entrega la cosa, y ahí es donde todo el mundo pega con cinta cinco herramientas distintas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Una librería de healthcheck estilo &lt;code&gt;psutil&lt;/code&gt; porque Kubernetes quiere &lt;code&gt;/healthz&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python-decouple&lt;/code&gt; o &lt;code&gt;pydantic-settings&lt;/code&gt; para env vars, más tu propia clase &lt;code&gt;Secret&lt;/code&gt; que con suerte no termina en logs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;opentelemetry-instrumentation-fastapi&lt;/code&gt; más &lt;code&gt;opentelemetry-exporter-otlp-proto-http&lt;/code&gt; más &lt;code&gt;opentelemetry-instrumentation-sqlalchemy&lt;/code&gt; más cualquiera sea la combinación correcta de versiones este mes.&lt;/li&gt;
&lt;li&gt;Un Dockerfile copiado de un post de blog, con tres cosas mal para tu setup.&lt;/li&gt;
&lt;li&gt;Un &lt;code&gt;docker-compose.yml&lt;/code&gt; que está bien excepto por el env var que tiene que venir de &lt;code&gt;.env.production&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Un &lt;code&gt;Makefile&lt;/code&gt; o &lt;code&gt;justfile&lt;/code&gt; con los comandos reales, o un YAML de CI que hace el equivalente.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Llevo diez años haciendo esto en cada proyecto. Es la parte donde el lenguaje deja de ayudarte. &lt;strong&gt;Fitz se niega a hacer eso.&lt;/strong&gt; Deployment está en el lenguaje.&lt;/p&gt;

&lt;p&gt;Acá está el stack completo de producción en Fitz, y qué reemplaza cada pieza.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks como decoradores
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(43928)
fn main() =&amp;gt; 0

@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) =&amp;gt; true,
        Err(_) =&amp;gt; false,
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/healthz&lt;/code&gt; y &lt;code&gt;/readyz&lt;/code&gt; &lt;strong&gt;se auto-montan&lt;/strong&gt; en el router HTTP. Nada para importar, ninguna librería para configurar. El return value de la función determina el HTTP status (200 si true, 503 si false). Kubernetes contento.&lt;/p&gt;

&lt;p&gt;El compilador también hace cumplir el shape: la función retorna &lt;code&gt;Bool&lt;/code&gt; o &lt;code&gt;Result&amp;lt;Bool&amp;gt;&lt;/code&gt;, toma un &lt;code&gt;DbConn&lt;/code&gt; si lo necesita (el runtime lo inyecta). Si te olvidás del &lt;code&gt;@readyz&lt;/code&gt; entero, el endpoint de readiness simplemente no existe — no hay librería que "casi configures" mal.&lt;/p&gt;

&lt;p&gt;En Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# requirements.txt: starlette-healthcheck, pyhealthcheck, ...
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;healthcheck&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HealthCheck&lt;/span&gt;
&lt;span class="n"&gt;health&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HealthCheck&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;db_check&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="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db_check&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/healthz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# acordate de montarlo
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@readyz
async fn readiness(db: DbConn) -&amp;gt; Bool {
    return match db.exec("SELECT 1").await { Ok(_) =&amp;gt; true, Err(_) =&amp;gt; false }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso es todo. Misma semántica, sin librería, sin paso de mounting, sin docs que tenés que volver a leer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets como tipos opacos
&lt;/h2&gt;

&lt;p&gt;El bug que más vi en código de producción: alguien loguea la password, la API key, el JWT secret. El logger entrega a Loki / Splunk / Sentry / lo que sea. El secret está ahora en logs de producción. Todo el mundo está de acuerdo en que esto es malo. Nadie tiene una buena respuesta en las librerías estándar — &lt;code&gt;os.environ["FOO"]&lt;/code&gt; es solo un string. &lt;code&gt;pydantic.SecretStr&lt;/code&gt; existe pero tenés que opt-in en todos lados y la gente se olvida.&lt;/p&gt;

&lt;p&gt;Fitz hace de los secrets un tipo de primera clase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")
let DB_URL: Secret&amp;lt;Str&amp;gt; = secret("DATABASE_URL")
let LOG_LEVEL: Str = config("LOG_LEVEL", "info")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres cosas siguen del hecho de que &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; sea un tipo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;print(JWT_SECRET)&lt;/code&gt; imprime `"&lt;/strong&gt;&lt;em&gt;"`&lt;/em&gt;*. El trait Display redacta el valor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La serialización JSON redacta&lt;/strong&gt;. &lt;code&gt;log.info("config", { jwt: JWT_SECRET })&lt;/code&gt; envía &lt;code&gt;"jwt": "***"&lt;/code&gt; a tu sistema de observability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hay exactamente una forma de exponer el valor&lt;/strong&gt;: &lt;code&gt;JWT_SECRET.expose()&lt;/code&gt;. Explícita, grepeable. El code review puede auditar cada call site en segundos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;config("LOG_LEVEL", "info")&lt;/code&gt; es el hermano no-secret. Mismo lookup de env var, mismo default, tipo &lt;code&gt;Str&lt;/code&gt; plano — sin redacción porque no es sensible. El sistema de tipos hace la distinción obvia en lugar de depender de convenciones de nombres.&lt;/p&gt;

&lt;p&gt;Combinado con el migrator (Parte 2), los secrets de Postgres viven en &lt;code&gt;Secret&amp;lt;Str&amp;gt;&lt;/code&gt; desde el momento en que entran al binario hasta que se entregan al driver. No hay variable string con la password dando vueltas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability con un solo env var
&lt;/h2&gt;

&lt;p&gt;Tracing y métricas son las partes de observability de producción que todo el mundo quiere y nadie quiere configurar. El SDK Python de OpenTelemetry actualmente te pide que:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Instales &lt;code&gt;opentelemetry-api&lt;/code&gt;, &lt;code&gt;opentelemetry-sdk&lt;/code&gt;, el exporter para tu protocolo, y un paquete de instrumentation por cada librería que uses.&lt;/li&gt;
&lt;li&gt;Configures el tracer provider, el meter provider, los resource attributes, el sampler.&lt;/li&gt;
&lt;li&gt;Lo enganches en un hook de startup.&lt;/li&gt;
&lt;li&gt;Esperes que ninguna de las versiones de esos paquetes se haya roto entre releases de Python.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;En Fitz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://jaeger:4318 ./mybin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esa es la integración. Cada request HTTP abre un span que exporta a OTLP. El span lleva &lt;code&gt;http.method&lt;/code&gt;, &lt;code&gt;http.target&lt;/code&gt; (template de la ruta, no path con params), &lt;code&gt;http.status_code&lt;/code&gt;, &lt;code&gt;duration_ms&lt;/code&gt;. El &lt;code&gt;trace_id&lt;/code&gt; y &lt;code&gt;span_id&lt;/code&gt; se propagan a cada &lt;code&gt;log.info(...)&lt;/code&gt; adentro del handler — cuando grepeás Jaeger por trace_id, encontrás cada log line relacionado en todos los servicios al instante.&lt;/p&gt;

&lt;p&gt;Cuando el env var &lt;strong&gt;no&lt;/strong&gt; está seteado: cero overhead, cero llamadas de red, ninguna tarea de exporter corriendo.&lt;/p&gt;

&lt;p&gt;Podés opt-out por ruta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(observability=false)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Podés agregar spans explícitos adentro de la lógica de negocio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    let validated = validate(order)?
    let charged = charge(validated).await?
    return Ok(receipt_for(charged))
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@trace&lt;/code&gt; abre un &lt;code&gt;tracing::info_span!&lt;/code&gt;. &lt;code&gt;@metric&lt;/code&gt; registra un histograma &lt;code&gt;&amp;lt;name&amp;gt;_duration_seconds&lt;/code&gt; y un counter &lt;code&gt;&amp;lt;name&amp;gt;_calls_total&lt;/code&gt;, populados al hacer drop del scope de la función — funciona con paths &lt;code&gt;return&lt;/code&gt; explícitos, sin código muerto.&lt;/p&gt;

&lt;p&gt;Las métricas también se exponen en &lt;code&gt;/metrics&lt;/code&gt; (formato Prometheus) si lo activás:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(prometheus=true)
fn main() =&amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para tiendas OpenTelemetry-native, el exporter OTLP envía también las métricas — ambos backends funcionan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs estructurados con auto-correlación
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log.info("user.signup", {
    user_id: user.id,
    email: user.email,
    plan: "free",
})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso emite una línea JSON a stderr con &lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;msg&lt;/code&gt;, los fields explícitos, y &lt;strong&gt;automáticamente&lt;/strong&gt; el &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;span_id&lt;/code&gt; del request HTTP actual. Sin llamada a &lt;code&gt;tracer.get_current_span()&lt;/code&gt;. El wrapper HTTP setea el contexto; el logger lo lee. Correlacionás logs y traces por &lt;code&gt;trace_id&lt;/code&gt; sin pensar en eso.&lt;/p&gt;

&lt;p&gt;Si valores &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; aparecen en los kwargs, se redactan antes de serializar. No hay forma de loguear accidentalmente un secret salvo escribir &lt;code&gt;.expose()&lt;/code&gt; explícito.&lt;/p&gt;

&lt;p&gt;Pretty-print en dev, JSON en producción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;./mybin
2026-06-05 14:23:11 INFO http.access &lt;span class="nv"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;GET &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/users/&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200 &lt;span class="nv"&gt;duration_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;12 &lt;span class="nv"&gt;trace_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ab12cd34...
2026-06-05 14:23:11 INFO user.lookup &lt;span class="nv"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;42 &lt;span class="nv"&gt;found&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="nv"&gt;$ FITZ_LOG_FORMAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json ./mybin
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"timestamp"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-06-05T14:23:11Z"&lt;/span&gt;,&lt;span class="s2"&gt;"level"&lt;/span&gt;:&lt;span class="s2"&gt;"INFO"&lt;/span&gt;,&lt;span class="s2"&gt;"msg"&lt;/span&gt;:&lt;span class="s2"&gt;"http.access"&lt;/span&gt;,&lt;span class="s2"&gt;"method"&lt;/span&gt;:&lt;span class="s2"&gt;"GET"&lt;/span&gt;,&lt;span class="s2"&gt;"target"&lt;/span&gt;:&lt;span class="s2"&gt;"/users/{id}"&lt;/span&gt;,&lt;span class="s2"&gt;"status"&lt;/span&gt;:200,&lt;span class="s2"&gt;"duration_ms"&lt;/span&gt;:12,&lt;span class="s2"&gt;"trace_id"&lt;/span&gt;:&lt;span class="s2"&gt;"ab12cd34..."&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El happy path de Loki/Datadog/Splunk es el mismo JSON estés usando OpenTelemetry o no. Ningún agente re-parsea prefijos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature flags como decorador
&lt;/h2&gt;

&lt;p&gt;Los feature flags estilo &lt;code&gt;unleash&lt;/code&gt;/&lt;code&gt;launchdarkly&lt;/code&gt; son normalmente una llamada de servicio por evaluación. Para la mayoría de los proyectos, la respuesta correcta es mucho más simple: una flag en tu config, un override por env var, un 404 si la flag está off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@flag("new-checkout")
@post("/v2/checkout")
fn v2_checkout(body: Cart) -&amp;gt; Receipt { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos fuentes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sección &lt;code&gt;[flags]&lt;/code&gt; en &lt;code&gt;fitz.toml&lt;/code&gt; — defaults compile-time horneados al binario.&lt;/li&gt;
&lt;li&gt;Env vars &lt;code&gt;FITZ_FLAG_&amp;lt;NAME&amp;gt;&lt;/code&gt; — override runtime sin recompilar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Default es &lt;code&gt;false&lt;/code&gt; (fail-safe). Cuando la flag está off, el handler HTTP/WS retorna 404 — y la ruta está gateada &lt;strong&gt;antes&lt;/strong&gt; de que corran los middlewares y la auth, así que no gastás ciclos en algo que el user no puede alcanzar de todas formas.&lt;/p&gt;

&lt;p&gt;Adentro de la lógica de negocio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if flag("show-experimental-banner") {
    show_banner()
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flags.list()&lt;/code&gt; enumera flags conocidas (config + env). &lt;code&gt;flags.is_enabled("name")&lt;/code&gt; es el alias.&lt;/p&gt;

&lt;p&gt;El modelo no está tratando de reemplazar a LaunchDarkly. Está tratando de eliminar la excusa para commitear &lt;code&gt;if user.id == 42&lt;/code&gt; y gatear features para testing. Servicio de feature flags real cuando necesitás targeting, porcentajes, audit log. Flags built-in cuando solo querés un kill switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dockerfile generado de tu AST
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz docker init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lee tu &lt;code&gt;main.fitz&lt;/code&gt; y escribe tres archivos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Dockerfile&lt;/code&gt; — multi-stage. El builder usa la imagen toolchain de Fitz. La stage de runtime es &lt;code&gt;gcr.io/distroless/cc-debian12&lt;/code&gt; (o &lt;code&gt;python:3.12-slim-bookworm&lt;/code&gt; si hay un &lt;code&gt;from python import ...&lt;/code&gt; en tu código — distroless no puede hostear CPython).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.dockerignore&lt;/code&gt; — defaults que matchean los smell tests (&lt;code&gt;target/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;, &lt;code&gt;.env*&lt;/code&gt;, &lt;code&gt;__pycache__/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; — tu app más la infraestructura que necesita. &lt;code&gt;db.connect(...)&lt;/code&gt; en tu código agrega &lt;code&gt;postgres:16-alpine&lt;/code&gt; con healthcheck y volumen &lt;code&gt;pgdata&lt;/code&gt;. &lt;code&gt;@server(8080)&lt;/code&gt; setea &lt;code&gt;EXPOSE 8080&lt;/code&gt;. &lt;code&gt;@cron&lt;/code&gt; agrega &lt;code&gt;restart: unless-stopped&lt;/code&gt;. Healthcheck contra &lt;code&gt;/healthz&lt;/code&gt; porque hay un decorador &lt;code&gt;@healthz&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La detección es &lt;strong&gt;AST-only&lt;/strong&gt; (~50ms). No ejecuta tu programa, no toca el disco, no probe ports. Lee el árbol sintáctico, busca decoradores y calls landmarks, llena el template correcto.&lt;/p&gt;

&lt;p&gt;Esta es la parte de deployment que tuve mal en cada proyecto: el Dockerfile que está casi bien pero no tiene libpq para Postgres, el compose que está casi bien pero montea el volumen equivocado. &lt;code&gt;fitz docker init&lt;/code&gt; lo agarra bien la primera vez porque el AST le dice qué necesitás.&lt;/p&gt;

&lt;p&gt;Los archivos se commitean. Editalos cuando los necesites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fitz docker init        &lt;span class="c"&gt;# generar&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="c"&gt;# editás Dockerfile, editás docker-compose.yml — son archivos normales&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git add Dockerfile docker-compose.yml .dockerignore
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fitz docker build&lt;/code&gt; es el wrapper fino que corre &lt;code&gt;docker build -t &amp;lt;pkg-name&amp;gt;:latest .&lt;/code&gt; con el working directory correcto.&lt;/p&gt;

&lt;h2&gt;
  
  
  fitz deploy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build de imagen y push al registry (saltá el push con --no-push).&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/api:v1

&lt;span class="c"&gt;# Levantá el stack compose local (con -d por default; --no-detach para foreground).&lt;/span&gt;
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Estos &lt;strong&gt;no son&lt;/strong&gt; herramientas nuevas. Son wrappers finos sobre &lt;code&gt;docker build&lt;/code&gt;/&lt;code&gt;docker push&lt;/code&gt; y &lt;code&gt;docker compose up&lt;/code&gt;. El punto no es inventar un sistema de deploy; el punto es que el comando de deploy existe en la misma toolchain que &lt;code&gt;fitz build&lt;/code&gt;. No tenés que mantener un &lt;code&gt;deploy.sh&lt;/code&gt; al lado de tu código.&lt;/p&gt;

&lt;p&gt;Targets en el MVP: &lt;code&gt;docker&lt;/code&gt; y &lt;code&gt;compose&lt;/code&gt;. Targets explícitamente &lt;strong&gt;fuera&lt;/strong&gt; del MVP: &lt;code&gt;fly&lt;/code&gt;, &lt;code&gt;railway&lt;/code&gt;, &lt;code&gt;k8s&lt;/code&gt;. Para esos, corré &lt;code&gt;flyctl deploy&lt;/code&gt; / &lt;code&gt;railway up&lt;/code&gt; / &lt;code&gt;kubectl apply&lt;/code&gt; directo — ya son buenas herramientas, Fitz no necesita re-envolverlas. Si aparece demanda por un target específico más adelante, se puede agregar — el helper crate son &lt;code&gt;~430 líneas&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cómo se ve end-to-end
&lt;/h2&gt;

&lt;p&gt;Un servicio real en Fitz hoy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@server(8080, prometheus=true)
fn main() =&amp;gt; 0

let DB_URL: Secret&amp;lt;Str&amp;gt; = secret("DATABASE_URL")
let JWT_SECRET: Secret&amp;lt;Str&amp;gt; = secret("JWT_SECRET")
let db = db.connect(DB_URL.expose())

@healthz
fn liveness() -&amp;gt; Bool =&amp;gt; true

@readyz
async fn readiness() -&amp;gt; Bool {
    return match db.exec("SELECT 1").await { Ok(_) =&amp;gt; true, Err(_) =&amp;gt; false }
}

@table("users")
type User { @primary id: Int, email: Str, name: Str, role: Str }

@auth_provider
fn check_token(headers: Map&amp;lt;Str, Str&amp;gt;) -&amp;gt; Result&amp;lt;User&amp;gt; { /* verify JWT */ }

@authenticated
@get("/me")
fn me(user: User) -&amp;gt; User =&amp;gt; user

@trace(name="charge")
@metric(name="charges")
@requires("billing")
@post("/charge")
async fn charge(body: ChargeRequest, user: User) -&amp;gt; Result&amp;lt;Receipt&amp;gt; {
    log.info("charge.attempt", { user_id: user.id, amount: body.amount })
    let receipt = stripe_charge(body).await?
    return Ok(receipt)
}

@cron("0 0 3 * * *", retry={max: 5, backoff: "exponential"}, store=db)
async fn cleanup_expired_tokens() {
    auth.cleanup_expired(db).await?
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lo deployás:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fitz docker init    &lt;span class="c"&gt;# genera Dockerfile + compose con postgres + healthcheck&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"deploy setup"&lt;/span&gt;
fitz deploy docker &lt;span class="nt"&gt;--tag&lt;/span&gt; mycorp/api:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En producción:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://collector:4318 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  mycorp/api:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese binario:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto-montea &lt;code&gt;/healthz&lt;/code&gt;, &lt;code&gt;/readyz&lt;/code&gt;, &lt;code&gt;/openapi.json&lt;/code&gt;, &lt;code&gt;/docs&lt;/code&gt;, &lt;code&gt;/metrics&lt;/code&gt;, &lt;code&gt;/asyncapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Exporta cada request HTTP como span OpenTelemetry con &lt;code&gt;trace_id&lt;/code&gt; propagado a logs.&lt;/li&gt;
&lt;li&gt;Valida JWTs con passwords hasheadas Argon2id.&lt;/li&gt;
&lt;li&gt;Corre cleanup scheduled con retry persistente sobre las tablas &lt;code&gt;fitz_cron_jobs&lt;/code&gt;/&lt;code&gt;fitz_cron_runs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Redacta cada &lt;code&gt;Secret&amp;lt;T&amp;gt;&lt;/code&gt; de logs, JSON responses, y fields estructurados.&lt;/li&gt;
&lt;li&gt;Maneja SIGTERM gracefully (drainando requests, después exit).&lt;/li&gt;
&lt;li&gt;Compila a ~18 MB de binario nativo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Escribiste ~50 líneas de código de negocio. El resto es el lenguaje haciendo lo que el lenguaje debería hacer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que todavía no está acá (honesto)
&lt;/h2&gt;

&lt;p&gt;Te dije la verdad en la Parte 1: un solo dev. La historia de deployment tiene gaps conocidos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fitz deploy fly&lt;/code&gt;&lt;/strong&gt; / &lt;code&gt;fitz deploy railway&lt;/code&gt; / &lt;code&gt;fitz deploy k8s&lt;/code&gt; no están construidos. Usá &lt;code&gt;flyctl deploy&lt;/code&gt; o &lt;code&gt;railway up&lt;/code&gt; o &lt;code&gt;kubectl apply&lt;/code&gt; directo. Los CLIs nativos son excelentes — Fitz no necesita re-envolverlos hoy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config de sidecar log shipping&lt;/strong&gt; en el compose generado. Si querés Fluent Bit / Vector / Loki Promtail cableado, editá el compose a mano. El autogen cubre el shape del programa, no el backend de observability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits de CPU/memoria en compose&lt;/strong&gt;. El MVP no los setea — deploys de producción (compose stack a un server) deberían agregar &lt;code&gt;deploy.resources.limits&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SBOM / firma de imagen&lt;/strong&gt;. No se autogenera. La imagen es output de &lt;code&gt;docker build&lt;/code&gt;. Firmá con &lt;code&gt;cosign&lt;/code&gt; si lo necesitás.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Todo lo demás de arriba está en v0.15.0 hoy, con tests, con paridad bit-a-bit &lt;code&gt;fitz run&lt;/code&gt; ↔ &lt;code&gt;fitz build&lt;/code&gt;, con ejemplos en los docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué importa
&lt;/h2&gt;

&lt;p&gt;El split 80/20 que describí al principio — 80% código divertido, 20% pegoteo de producción — es un impuesto sobre cada lenguaje diseñado antes de 2015. Los lenguajes que diseñaron para producción desde el día uno (Go es el ejemplo obvio) cambiaron otras cosas para llegar ahí. Fitz está tratando de mantener el feel gradual-typed, expression-rich, async-first de Python — y aún así taxar producción con cero peso extra.&lt;/p&gt;

&lt;p&gt;Si pasaste por una semana de deploy y sentiste "esto no debería requerir cinco pestañas de Stack Overflow", ese es el sentimiento al que estoy construyendo para eliminar.&lt;/p&gt;

&lt;p&gt;Probalo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / macOS / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://thegreekman76.github.io/fitz/install.sh | sh

&lt;span class="c"&gt;# Windows (PowerShell)&lt;/span&gt;
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

&lt;span class="c"&gt;# Reabrí la terminal, después:&lt;/span&gt;
fitz new mi-api-prod &lt;span class="nt"&gt;--http&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;mi-api-prod
&lt;span class="c"&gt;# Editá main.fitz para sumar @healthz, @table, una ruta HTTP&lt;/span&gt;
fitz docker init
fitz deploy compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para VSCode (recomendado — hover con tipos, autocomplete, signature help): bajá el &lt;code&gt;fitz-lang-&amp;lt;plataforma&amp;gt;.vsix&lt;/code&gt; desde la &lt;a href="https://github.com/Thegreekman76/fitz/releases" rel="noopener noreferrer"&gt;página de releases&lt;/a&gt; y &lt;code&gt;code --install-extension fitz-lang-&amp;lt;plataforma&amp;gt;.vsix --force&lt;/code&gt;. El Language Server viene incluido.&lt;/p&gt;

&lt;p&gt;Vas a tener un servicio con healthchecks, observability, y Docker compose en tu proyecto en menos de cinco minutos. Avisame qué se rompió.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Docs y curso&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Capítulo de la guía sobre deployment&lt;/strong&gt;: &lt;a href="https://thegreekman76.github.io/fitz/guide/" rel="noopener noreferrer"&gt;thegreekman76.github.io/fitz/guide/#35-deployment&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/docs/roadmap.md" rel="noopener noreferrer"&gt;docs/roadmap.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CHANGELOG&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Issues&lt;/strong&gt;: &lt;a href="https://github.com/Thegreekman76/fitz/issues" rel="noopener noreferrer"&gt;github.com/Thegreekman76/fitz/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hasta la próxima.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>opensource</category>
      <category>observability</category>
    </item>
  </channel>
</rss>
