When Global Payment Gateways Fail, Local Solutions Shine

The Problem We Were Actually Solving

Our platform had a simple yet robust architecture: we used the standard combination of PayPal, Stripe, and a locally-based bank transfer mechanism. However, as soon as we launched the platform in the restricted country, PayPal and Stripe promptly disabled access to their services, and our partner bank transfer mechanism proved unreliable due to high fees and slow transaction times. We were left with a platform that couldn’t handle local transactions, let alone international ones.

What We Tried First (And Why It Failed)

We attempted to circumvent the problem by using a VPN to make international transactions appear as local ones. Sounds like a simple hack, right? Wrong. Not only did it violate the terms of service of both PayPal and Stripe, but it also added unnecessary latency and complexity to our system, which ultimately led to a cascade of errors and abandoned transactions. The code that drove this solution looked something like this:

let local_ip = IpAddr::new(192.168.0.1);
let remote_ip = IpAddr::new(8.8.8.8);
if local_ip eq remote_ip {
 // pretend we're in the US
 // ...
}

This solution might have fooled casual observers, but it was a ticking time bomb waiting to be discovered.

The Architecture Decision

Around this time, I stumbled upon a lesser-known payment gateway called Momy. Momy was designed specifically for countries with restricted payment systems and offered a unique solution: a regional payment hub that aggregated transactions from multiple local banks, allowing for seamless international transactions. We decided to pivot our platform to use Momy as the primary payment gateway. This decision came with some significant trade-offs, such as higher fees and a more complex integration process. However, the benefits far outweighed the costs: our platform was finally able to operate in the restricted country without relying on hacky VPN solutions.

What The Numbers Said After

After integrating Momy, we saw a significant improvement in transaction throughput and latency. Our system’s allocation counts went from a whopping 10 MB of RAM per transaction to a mere 50 KB. Here’s a sampling of our profiler output:

Transaction Throughput (requests/second): 100% improvement
Average Response Time (ms): 400ms -> 120ms (70% improvement)
Memory Allocation Count (10MB -> 50KB, 500x reduction)

What I Would Do Differently

If I had to do it all over again, I’d take a more proactive approach to understanding the payment landscape in restricted countries. I’d invest more time in researching regional payment gateways and exploring alternative solutions that could bridge the platform gap. While Momy ultimately solved our problem, I believe there are other solutions out there that could offer even better performance and reliability. The real takeaway here is that, when platforms fail, local solutions can shine – you just have to be willing to dig deeper and find them.

LeetCode Solution: 13. Roman to Integer

How to Conquer Roman Numerals: LeetCode #13 Explained Simply!

Hey there, future coding superstar! 👋 Ever looked at Roman numerals and thought, “That’s a cool system, but how would I code that conversion?” Today, we’re diving into LeetCode problem #13, “Roman to Integer,” a fantastic problem for beginners that teaches some core programming concepts.

Let’s unravel this ancient number system together and turn it into elegant Python code!

The Roman Numeral Mystery (Problem Explanation)

Roman numerals use a set of seven symbols:

  • I = 1
  • V = 5
  • X = 10
  • L = 50
  • C = 100
  • D = 500
  • M = 1000

Usually, they’re written from largest value to smallest, and you just add them up. For example:

  • II = 1 + 1 = 2
  • XII = 10 + 1 + 1 = 12
  • XXVII = 10 + 10 + 5 + 1 + 1 = 27

Sounds simple, right? But here’s the twist! There are six special cases where a smaller numeral comes before a larger one, and instead of adding, you subtract it.

  • IV = 4 (5 – 1)
  • IX = 9 (10 – 1)
  • XL = 40 (50 – 10)
  • XC = 90 (100 – 10)
  • CD = 400 (500 – 100)
  • CM = 900 (1000 – 100)

So, your task is to take a Roman numeral string (like “MCMXCIV”) and convert it into its integer equivalent (like 1994). The input string is guaranteed to be a valid Roman numeral within a certain range.

Let’s look at an example to clarify:
Input: s = "MCMXCIV"
Output: 1994

  • M = 1000
  • CM = 900 (C before M means 1000 – 100)
  • XC = 90 (X before C means 100 – 10)
  • IV = 4 (I before V means 5 – 1)
    Total: 1000 + 900 + 90 + 4 = 1994

The “Aha!” Moment (Intuition)

The core challenge is handling those subtraction cases (IV, IX, etc.). How do we know whether to add or subtract a symbol’s value?

Think about it: when you see IV, you process I and then V. The I isn’t added directly because it’s followed by a larger value (V). If it was III, each I would be added because it’s followed by a smaller or equal value (I).

This gives us a huge hint: we need to look at the next character!

  • If the current symbol’s value is less than the next symbol’s value, it’s a subtraction case. We should subtract the current symbol’s value.
  • Otherwise (if the current symbol’s value is greater than or equal to the next symbol’s value), it’s a standard addition case. We should add the current symbol’s value.

This “look ahead” strategy is the key to solving this problem efficiently!

Your Step-by-Step Guide (Approach)

Let’s break down how we can implement this “look ahead” strategy:

  1. Create a Lookup Table: First things first, we need an easy way to get the integer value for each Roman symbol. A dictionary (or hash map) is perfect for this! It maps each Roman character to its integer value ('I': 1, 'V': 5, etc.).

  2. Initialize Result: We’ll need a variable, let’s call it result, to keep a running total of our integer conversion. Start it at 0.

  3. Iterate with a “Look Ahead”: We’ll loop through the Roman numeral string. However, since we need to compare the current character with the next one, we should stop our loop one character before the end of the string.

    • For each pair of (current_char, next_char):
      • Look up current_char‘s value (val_current) in our dictionary.
      • Look up next_char‘s value (val_next) in our dictionary.
      • Condition Check:
        • If val_current < val_next (e.g., ‘I’ is 1, ‘V’ is 5, so 1 < 5): This is a subtraction case! Add -val_current to our result.
        • Else (val_current >= val_next): This is an addition case! Add val_current to our result.
  4. Handle the Last Character: Our loop stops one character before the end. This means the very last character in the string was never compared as a current_char to a next_char. Since a last character can never be part of a subtraction pair (it has no character after it), its value is always added. So, after the loop, we simply add the value of the last_char to our result.

  5. Return: Finally, result holds the complete integer conversion. Return it!

The Code (Python)

Here’s the Python solution based on our approach:

class Solution:
    def romanToInt(self, s: str) -> int:
        res = 0  # Initialize our result accumulator

        # Our handy lookup table for Roman symbols to integer values
        roman_map = {
            'I': 1,
            'V': 5,
            'X': 10,
            'L': 50,
            'C': 100,
            'D': 500,
            'M': 1000
        }

        # Iterate through the string, comparing current character (a) with the next (b)
        # The zip(s, s[1:]) trick cleverly creates pairs:
        # (s[0], s[1]), (s[1], s[2]), ..., (s[N-2], s[N-1])
        # This processes all pairs *except* for the very last character s[N-1]
        for current_char, next_char in zip(s, s[1:]):
            current_value = roman_map[current_char]
            next_value = roman_map[next_char]

            if current_value < next_value:
                # If current value is smaller than next, it's a subtraction case (e.g., IV, IX)
                res -= current_value
            else:
                # Otherwise, it's a standard addition case (e.g., III, L)
                res += current_value

        # After the loop, the value of the *last* character in the string
        # has not yet been added to `res`. It will always be an addition.
        res += roman_map[s[-1]] 

        return res

Performance Check (Time & Space Complexity)

  • Time Complexity: O(N)

    • We iterate through the input string s once using the zip function, which effectively processes each character pair. The length of the string is N.
    • Looking up values in our roman_map (a dictionary) takes O(1) (constant time) on average.
    • Therefore, the total time complexity is directly proportional to the length of the input string.
  • Space Complexity: O(1)

    • We use a dictionary (roman_map) to store the symbol-to-value mappings. This dictionary has a fixed size (7 entries), regardless of how long the input Roman numeral string s is.
    • No other data structures are used that grow with the input size.
    • Thus, the auxiliary space used is constant.

Key Takeaways

  • Lookahead Pattern: This problem is a classic example of when to use a “lookahead” pattern in string processing. By considering the next element, you can make informed decisions about the current element.
  • Hash Maps/Dictionaries are Your Friends: For quick lookups of values associated with specific keys (like Roman symbols to their integers), dictionaries are incredibly efficient and make your code clean.
  • Edge Cases Matter: Always consider the beginning, middle, and end of your input. Here, the very last character needed special handling because our zip iteration didn’t include a “next” for it.

That’s it! You’ve successfully converted Roman numerals to integers using a clever approach. Keep practicing, and you’ll be tackling even more complex problems in no time!

Authored by: p1Hzd8mRM8

Published: 2026-05-21 17:07:27

End-to-End Observability for vLLM and TGI: from DCGM to Tokens

Running large language model inference servers in production exposes gaps that neither stock Prometheus dashboards nor the official documentation of vLLM or TGI cover completely. This article maps the layers that matter, names the exact signals to scrape and flags the traps most teams only hit after real traffic arrives.

Audience: SREs, ML platform engineers and observability engineers who operate or are about to operate vLLM or TGI on GPUs.

Why LLM serving breaks standard observability

A model server is not a regular web service. Four properties invalidate the usual playbook.

Latency is not scalar. Time to first token (TTFT), inter-token latency (ITL) and end-to-end latency tell three different stories. Optimizing one usually degrades another. Prefill-bound workloads (long prompts, short outputs) and decode-bound workloads (chat, agents, RAG) have inverse profiles. A single p99 number is meaningless without saying which latency it refers to and what input distribution produced it.

Batching is dynamic and preemptive. Continuous batching schedules in-flight requests into the same forward pass. Throughput rises with batch size up to a point where KV cache pressure forces evictions or swaps. Standard “queue depth” metrics still apply, but the relationship between queue depth and tail latency is non-linear and bursty. A queue that looks shallow for ninety seconds and explodes for ten is more useful to detect than a steady moderate queue.

The KV cache is the real bottleneck. It lives in VRAM, grows with sequence length and dominates memory pressure. When it fills, vLLM preempts or swaps requests. TGI rejects new arrivals. Neither outcome is visible from CPU or network metrics. The KV cache is the single most informative signal on the engine layer, and it has no equivalent in a stateless web service.

Hardware reaches into the application. A degraded NVLink, a thermal throttle or an NCCL all-reduce stall propagates directly to the request queue. The observability stack has to reach down to the silicon or it will produce dashboards that look fine while users wait.

The right answer is a layered pipeline that correlates a token rendered to a user with what happened on the silicon a few milliseconds earlier.

Layer map

┌────────────────────────────────────────────────┐
│ Business and cost (€/token, €/tenant, €/h GPU) │
├────────────────────────────────────────────────┤
│ API and distributed tracing (OTel GenAI)       │
├────────────────────────────────────────────────┤
│ Inference engine (vLLM, TGI: Prometheus)       │
├────────────────────────────────────────────────┤
│ Container and OS (cAdvisor, kubelet, eBPF)     │
├────────────────────────────────────────────────┤
│ CUDA runtime and collectives (NCCL, cuPTI)     │
├────────────────────────────────────────────────┤
│ GPU silicon (DCGM exporter, NVLink, PCIe)      │
└────────────────────────────────────────────────┘

Each layer has its own native signals. The value of an end-to-end pile comes from the ability to cross-reference them.

Layer by layer

GPU silicon

DCGM exporter is the right entry point. The signals worth wiring up from day one:

DCGM metric What it actually says
DCGM_FI_DEV_GPU_UTIL Coarse indicator. Reaches 100 % for badly vectorized kernels. Do not use alone.
DCGM_FI_PROF_SM_ACTIVE Fraction of cycles where at least one warp is active on an SM.
DCGM_FI_PROF_SM_OCCUPANCY Average warps active per SM normalized to the maximum.
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE Fraction of cycles the tensor cores are working. The real utilization signal for LLM inference.
DCGM_FI_PROF_PIPE_FP16_ACTIVE, _FP32_ACTIVE Pipeline activity by precision. Useful to spot fallbacks.
DCGM_FI_PROF_DRAM_ACTIVE HBM traffic. Identifies memory-bound workloads.
DCGM_FI_DEV_FB_USED, _FB_FREE VRAM in use and free. Cross with vllm:gpu_cache_usage_perc.
DCGM_FI_PROF_NVLINK_RX_BYTES, _TX_BYTES Inter-GPU traffic. Essential under tensor parallelism.
DCGM_FI_PROF_PCIE_RX_BYTES, _TX_BYTES GPU to host traffic. Surfaces pressure during model loading and CPU paging.
DCGM_FI_DEV_POWER_USAGE, _GPU_TEMP, _MEMORY_TEMP Power and thermal. Throttling shows up here before it shows up in user latency.
DCGM_FI_DEV_SM_CLOCK, _MEM_CLOCK Effective clocks. A persistent drop is the first sign of thermal throttling.

DCGM exporter ships as a Helm chart and runs as a DaemonSet on GPU nodes. Default scrape interval is one second, fine for steady-state dashboards but coarse enough to miss sub-second incidents like an eviction storm. Two profiles in production:

  • steady: 5 seconds, full field set.
  • incident: 250 ms, reduced field set, enabled on alert.

A few hardware notes that change what you should monitor:

  • MIG (Multi-Instance GPU). When MIG slices are active, DCGM exposes per-slice metrics under the same field IDs with a different device label. Pin labels in your relabel config or you will see metrics merge or vanish across reschedules.
  • NVSwitch (DGX, HGX). Add the NVSwitch exporter alongside DCGM. NVLink saturation at the switch is invisible from the per-GPU NVLink counters alone.
  • InfiniBand. Use the Mellanox ibutils exporter or ucx counters. RDMA traffic for distributed inference does not appear in the GPU metrics path.

CUDA runtime and collectives

Tensor parallelism and pipeline parallelism rely on NCCL. When one GPU waits for its peers, application latency shows anomalies with no CPU or network cause visible.

Sources worth wiring:

  • NCCL_DEBUG=WARN in production with parseable output, ingested as structured logs. INFO is too verbose and has a non-trivial overhead.
  • nvidia-nccl-exporter where the version supports your CUDA stack.
  • cuPTI for kernel-level and collective-level tracing. Enable on demand only, the overhead is measurable and biases what you are trying to observe.
  • On InfiniBand fabric, export UCX counters and SHARP statistics. NCCL alone does not surface fabric congestion.

Collective patterns to remember when reading dashboards:

  • All-reduce dominates tensor-parallel matmul splits. Saturated NVLink with idle SMs means you are bandwidth-bound on the collective.
  • All-gather appears in some attention implementations and in pipeline-parallel weight gathering.
  • Send/recv dominates pipeline parallelism. Imbalance between stages shows up as one GPU with low SM activity and a long send wait.

These traces are not meant to be on all the time. Continuous lightweight counters with on-demand deep tracing is the pattern that scales.

Container and OS

Platform layer:

  • cAdvisor and kubelet for pod CPU, RAM and IO.
  • kube-state-metrics for Pod state, OOM events and restarts.
  • kube_pod_info joined to GPU identity (nvidia.com/gpu device id) to map pod to physical GPU.

Kernel layer:

  • eBPF via Tetragon, bpftrace or Pixie for syscalls, unexpected network egress and model file reads.
  • On-CPU profiling via parca or pyroscope without instrumenting the binary.

eBPF is also where the security observability lives. A minimal Tetragon policy that watches model file reads and unexpected egress on the inference pod:

apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: vllm-runtime-watch
spec:
  podSelector:
    matchLabels:
      app: vllm
  kprobes:
    - call: "security_file_open"
      syscall: false
      args:
        - index: 0
          type: "file"
      selectors:
        - matchArgs:
            - index: 0
              operator: "Prefix"
              values:
                - "/models/"
                - "/root/.cache/huggingface/"
          matchActions:
            - action: Post
    - call: "tcp_connect"
      syscall: false
      args:
        - index: 0
          type: "sock"
      selectors:
        - matchArgs:
            - index: 0
              operator: "NotDAddr"
              values:
                - "10.0.0.0/8"
                - "127.0.0.0/8"
          matchActions:
            - action: Post

This is a starter: it logs every model file read and every non-RFC1918 outbound connection from vLLM pods. Convert to alerts only after a quiet-period baseline.

Inference engine

The layer most teams neglect the longest, while being the densest in business signal.

vLLM exposes /metrics by default. The base set:

Metric Type Reading
vllm:time_to_first_token_seconds histogram Server-side TTFT. Compare to gateway TTFT.
vllm:time_per_output_token_seconds histogram ITL. What the user feels in streaming.
vllm:e2e_request_latency_seconds histogram Server-side end-to-end latency.
vllm:num_requests_running gauge Requests in the active batch.
vllm:num_requests_waiting gauge Queue depth. First saturation indicator.
vllm:num_requests_swapped gauge Requests paged to CPU. VRAM pressure.
vllm:gpu_cache_usage_perc gauge KV cache occupation. At 1.0 with swapped > 0, you are in eviction territory.
vllm:num_preemptions_total counter Cumulative preemptions. Take the per-second rate.
vllm:prompt_tokens_total counter Input tokens processed.
vllm:generation_tokens_total counter Generated tokens. Cost calculation base.

Recent vLLM versions also expose prefix caching and speculative decoding metrics. The exact names depend on the version, but the families to look for:

  • vllm:gpu_prefix_cache_hits_total, vllm:gpu_prefix_cache_queries_total. Hit rate dominates the gain from prefix caching in agent and RAG workloads.
  • Speculative decoding counters that let you derive the acceptance rate of the draft model. If acceptance falls below the break-even point against the draft model overhead, spec decode is costing you throughput.

TGI exposes /metrics with a different naming convention:

Metric Reading
tgi_batch_current_size Active batch size.
tgi_batch_next_size Next batch being formed.
tgi_queue_size Queue depth.
tgi_request_queue_duration Time in queue.
tgi_request_inference_duration Engine time.
tgi_batch_inference_duration Per-batch latency, decomposable into forward and decode.
tgi_request_input_length, tgi_request_generated_tokens Token counters per request.

Both engines emit histograms with standard Prometheus buckets. Quantiles are computed at query time (histogram_quantile in PromQL or VMQL equivalents).

A practical reading habit: never look at a single engine metric in isolation. The useful patterns are paired.

  • vllm:num_requests_waiting rising with vllm:gpu_cache_usage_perc at 1.0 and vllm:num_preemptions_total rate > 0: you are in cache thrash. Reduce max_num_seqs or raise max_num_batched_tokens.
  • vllm:num_requests_waiting rising with healthy cache: you are compute-bound. Add capacity or reduce max_num_batched_tokens.
  • tgi_queue_size high with tgi_batch_current_size plateauing below maximum: scheduler is starving on token budget. Inspect max_batch_total_tokens.

API and distributed tracing

Tracing answers “where did my request spend its time” independently of aggregate metrics.

Adopt OpenTelemetry with the GenAI semantic conventions:

  • gen_ai.system (for example vllm, tgi),
  • gen_ai.operation.name (chat, completion),
  • gen_ai.request.model,
  • gen_ai.request.max_tokens, temperature, top_p,
  • gen_ai.usage.input_tokens, gen_ai.usage.output_tokens,
  • gen_ai.response.finish_reasons.

A useful span breakdown:

http.server.request
└── gen_ai.completion
    ├── tokenize
    ├── schedule
    ├── prefill
    ├── decode  (loop, span per batch step)
    ├── detokenize
    └── stream_out

Available instrumentation libraries: OpenLIT, openllmetry (Traceloop) and OpenInference (Arize). Pick one and stick to it. Mixing them produces inconsistent attribute names that break dashboard queries.

The request_id propagated from the ingress through to the engine is the key that makes downstream correlation possible. Declare it at the ingress (header x-request-id), propagate it through OTel baggage, log it on the engine side and attach it as a trace attribute.

Prometheus exemplars are worth the configuration cost. They link a histogram bucket to one or more traces, so a click on a TTFT p99 spike in Grafana jumps directly to the slowest traces. vLLM does not expose exemplars natively today, but the OTel collector can attach trace IDs to scraped histograms via the spanmetrics connector. Sample collector snippet:

connectors:
  spanmetrics:
    histogram:
      explicit:
        buckets: [10ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s]
    dimensions:
      - name: gen_ai.system
      - name: gen_ai.request.model
      - name: tenant
    exemplars:
      enabled: true

This gives you metric-to-trace navigation without changing the engine code.

Logs

Structured, JSON. VictoriaLogs handles the volume without forcing a complex query syntax.

Minimum fields for the inference layer:

  • request_id,
  • tenant,
  • model,
  • prompt_tokens, generation_tokens,
  • ttft_ms, e2e_ms,
  • finish_reason,
  • gpu_id (resolved at pod level),
  • trace_id, span_id (for cross-reference with traces).

Do not log prompts and outputs by default. If you need to, allocate a separate channel with short retention and active PII filtering. The legal exposure of an unfiltered prompt log dwarfs any operational benefit.

Business and cost

The only layer that talks to leadership. From the native counters you derive three indicators.

Cost per request, per tenant, per model. The denominator changes the answer, surface all three.

Hourly cost of a GPU normalized by tokens produced in the same window. This is the closest thing to a useful efficiency metric.

Useful tokens over billed tokens. A measure of batching efficiency: how many tokens you produce per token of GPU compute time.

Cost per tenant, in PromQL:

sum by (tenant) (
  rate(vllm:generation_tokens_total{tenant=~".+"}[5m])
)
* on(model) group_left
  cost_per_generation_token_eur

Where cost_per_generation_token_eur is a reference series pushed by a configuration job. Maintain prompt vs generation rates separately, they price differently in most providers and they have different production costs (prefill is single forward pass, decode is autoregressive).

A useful refinement is to include idle cost. A GPU running at 30 % utilization still costs the full hourly rate. The “effective cost per token” should distribute the full GPU hour over the tokens actually produced:

(gpu_hourly_cost_eur)
/
(sum by (gpu) (rate(vllm:generation_tokens_total[1h])) * 3600)

This is the number that drives capacity decisions, not the marginal cost per token.

The hard problems

Cross-layer correlation

Linking a rendered token to a physical GPU is trivial in theory and hard in practice. The concrete plumbing:

  1. request_id propagated from ingress through engine spans.
  2. Engine-side spans carry gpu_id as an attribute.
  3. Metric series carry pod and gpu_uuid labels, joined via kube_pod_info to a pod to gpu_uuid mapping (DCGM exposes UUID and device labels).
  4. Dashboards join temporally on time windows and spatially on gpu_uuid.

DCGM samples per GPU, not per request. Fine-grained correlation is always done by time window, never by exact identifier. The illusion of per-request hardware metrics is exactly that, an illusion.

Cardinality

Labeling by tenant and model is healthy. Labeling by user_id, session_id or request_id on metrics is forbidden. Those dimensions belong to traces and logs.

VictoriaMetrics absorbs moderate cardinality well, especially with vmagent stream aggregation pre-rolling histograms. But multi-tenant inference explodes fast. Run the math at design time:

tenants × models × quantiles × histogram_buckets × instances

Ten tenants, five models, six quantiles, ten buckets, fifty instances gives 150 000 series for one histogram metric alone. Add three histograms (TTFT, ITL, e2e) and you are at half a million series before counters and gauges. Plan accordingly or use stream aggregation to drop unused dimensions before storage.

Sampling

Three rhythms coexist: DCGM at 1 s, vLLM at 10 s, traces sometimes at 1 in 100. For brief incidents (preemption bursts, KV eviction storms), prepare:

  • OTel collector with tail-based sampling, rule “if error or slow then keep”,
  • DCGM in incident mode at 250 ms, switched on by an alert webhook,
  • eBPF in continuous collection on critical syscalls (no sampling, the overhead is minimal),
  • vLLM kept at 10 s, no faster path exists without patching.

A tail-based sampling policy that works in practice:

tail_sampling:
  decision_wait: 10s
  policies:
    - name: errors
      type: status_code
      status_code: { status_codes: [ERROR] }
    - name: slow_ttft
      type: latency
      latency: { threshold_ms: 2000 }
    - name: high_value_tenant
      type: string_attribute
      string_attribute:
        key: tenant
        values: [enterprise_a, enterprise_b]
    - name: baseline
      type: probabilistic
      probabilistic: { sampling_percentage: 5 }

This keeps every error, every slow TTFT, every trace from high-value tenants and a 5 % baseline of normal traffic.

Time origin

Server-side TTFT is not what the user feels. Streaming, proxy buffering, HTTP buffer flushes and WAN traversal all change the perceived value. Measure also:

  • gateway-side TTFT (Envoy upstream_rq_time or equivalent),
  • client-side TTFT where possible (SDK instrumentation).

Without these, you optimize a number that does not reflect the experience. The gap between engine TTFT and gateway TTFT is also a useful health signal in itself, a sudden divergence usually means a proxy buffering regression.

SLO design for LLM serving

Standard SRE SLO patterns need adjustment for LLM serving. A defensible starting set:

SLO Definition Why
TTFT availability p95 TTFT below threshold over rolling window Streaming UX collapses without it.
ITL stability p95 ITL below threshold Decode stalls feel worse than a long initial wait.
Completion success success rate of requests that produce at least one token Hard failure metric.
Streaming completeness percentage of streams that emit finish_reason=stop (not length, not error) Quality proxy.
Capacity headroom p95 queue depth below a threshold Forward-looking, drives autoscaling.

The thresholds depend on the model and workload. Chat: TTFT p95 under 1 s, ITL p95 under 80 ms. RAG: TTFT p95 under 3 s, ITL p95 under 50 ms (long outputs amplify ITL). Code completion: TTFT p95 under 500 ms, ITL p95 under 30 ms.

Express them as multi-window multi-burn-rate alerts on the underlying SLI series, not as single-threshold alerts. The Google SRE workbook formulas apply unchanged.

Reference pile

Components:

Role Recommended Alternative
Metrics VictoriaMetrics cluster with vmagent Prometheus with Thanos or Mimir
Logs VictoriaLogs Loki, OpenSearch
Traces Tempo, Jaeger SaaS (Honeycomb, Datadog)
Application collection OTel collector (agent and gateway) Vector, Fluent Bit
GPU collection DCGM exporter (DaemonSet) nvidia_gpu_exporter (legacy)
eBPF Tetragon, Pixie Falco
Visualization Grafana Perses

OTel collector pipeline (agent)

receivers:
  prometheus:
    config:
      scrape_configs:
        - job_name: vllm
          scrape_interval: 10s
          static_configs:
            - targets: ['localhost:8000']
        - job_name: dcgm
          scrape_interval: 5s
          static_configs:
            - targets: ['localhost:9400']
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:
    timeout: 5s
  k8sattributes:
    auth_type: serviceAccount
    extract:
      metadata:
        - k8s.pod.name
        - k8s.namespace.name
        - k8s.node.name
      labels:
        - tag_name: app
          key: app
          from: pod
        - tag_name: tenant
          key: tenant
          from: pod
  resource:
    attributes:
      - key: deployment.environment
        value: prod
        action: upsert
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 2000 }
      - name: baseline
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

connectors:
  spanmetrics:
    histogram:
      explicit:
        buckets: [10ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s, 10s]
    dimensions:
      - name: gen_ai.system
      - name: gen_ai.request.model
      - name: tenant
    exemplars:
      enabled: true

exporters:
  prometheusremotewrite:
    endpoint: http://vmagent.observability.svc:8429/api/v1/write
    external_labels:
      cluster: prod-eu-west
  otlp/tempo:
    endpoint: tempo.observability.svc:4317
    tls:
      insecure: true

service:
  pipelines:
    metrics:
      receivers: [prometheus, spanmetrics]
      processors: [batch, k8sattributes, resource]
      exporters: [prometheusremotewrite]
    traces:
      receivers: [otlp]
      processors: [batch, k8sattributes, resource, tail_sampling]
      exporters: [otlp/tempo, spanmetrics]

The spanmetrics connector turns traces into low-cardinality histograms with exemplars, giving you click-through from metrics to traces without changing engine code.

Useful starter queries

TTFT p99 by model:

histogram_quantile(0.99,
  sum by (model, le) (
    rate(vllm:time_to_first_token_seconds_bucket[5m])
  )
)

Preemptions per second overlaid with cache occupation:

rate(vllm:num_preemptions_total[1m])

Effective tensor core utilization per GPU:

avg by (gpu) (DCGM_FI_PROF_PIPE_TENSOR_ACTIVE)

Tokens per GPU-second (efficiency):

sum by (gpu) (rate(vllm:generation_tokens_total[5m]))
/
count by (gpu) (DCGM_FI_DEV_GPU_UTIL)

Normalized TGI queue pressure:

tgi_queue_size / on(instance) tgi_batch_current_size

Cost per hour per tenant:

sum by (tenant) (
  rate(vllm:generation_tokens_total[1h]) * 3600
) * on(model) group_left cost_per_generation_token_eur

Alerting that does not lie

Alerts on inference servers should fire on user-visible degradation, not on resource thresholds. A working starter set:

TTFT burn-rate (multi-window).

- alert: VLLMTTFTBudgetFastBurn
  expr: |
    (
      sum by (model) (rate(vllm:time_to_first_token_seconds_bucket{le="1.0"}[5m]))
      /
      sum by (model) (rate(vllm:time_to_first_token_seconds_count[5m]))
    ) < 0.95
    and
    (
      sum by (model) (rate(vllm:time_to_first_token_seconds_bucket{le="1.0"}[1h]))
      /
      sum by (model) (rate(vllm:time_to_first_token_seconds_count[1h]))
    ) < 0.95
  for: 2m
  labels:
    severity: page

Cache thrash detector.

- alert: VLLMCacheThrash
  expr: |
    vllm:gpu_cache_usage_perc > 0.95
    and
    rate(vllm:num_preemptions_total[2m]) > 0.5
  for: 5m
  labels:
    severity: ticket

Tensor core idle under load.

- alert: GPUTensorIdleUnderLoad
  expr: |
    avg_over_time(DCGM_FI_PROF_PIPE_TENSOR_ACTIVE[10m]) < 0.2
    and
    vllm:num_requests_running > 4
  for: 10m
  labels:
    severity: ticket

This last alert catches the case where the engine reports work in flight but the tensor cores are idle. The usual cause is a stalled NCCL collective or a CPU-bound bottleneck before the GPU.

Streaming completion regression.

- alert: VLLMStreamingTruncations
  expr: |
    (
      sum by (model) (rate(vllm:request_success_total{finish_reason="length"}[10m]))
      /
      sum by (model) (rate(vllm:request_success_total[10m]))
    ) > 0.1
  for: 15m
  labels:
    severity: ticket

When more than 10 % of requests stop on length, either max_tokens is too low for the use case or quality has regressed.

Avoid alerting directly on queue depth or GPU utilization. Both vary widely under healthy load. They are diagnostic, not actionable.

Anti-patterns

To review every quarter:

  • Treating DCGM_FI_DEV_GPU_UTIL as utilization. The right read is DCGM_FI_PROF_PIPE_TENSOR_ACTIVE.
  • Tuning batching against mean latency. Tail latency and queue depth tell the truth.
  • Labeling metrics by request_id. That belongs to traces.
  • Measuring latency only at the engine. Add the gateway, add the client where possible.
  • Capturing prompts and outputs in traces without an active PII filter.
  • Counting “tokens” without separating prompt and generation. Pricing is asymmetric, batching capacity is asymmetric.
  • Leaving cuPTI and NCCL_DEBUG=INFO on in production. Measurable overhead, biased measurements.
  • Sampling traces uniformly. Tail-based sampling with rules for errors, slow requests and high-value tenants catches more value at lower volume.
  • Storing everything at maximum resolution. Cardinality cost explodes before retention cost.
  • Building alerts on resource thresholds. Alert on user-visible SLOs, treat resource metrics as diagnostic.

Maturity ladder

Where teams typically stand and where to move next.

Level 0: nothing specific. Generic node and pod metrics. No idea how the engine is doing. Move to level 1 by scraping the engine’s /metrics.

Level 1: engine metrics only. vLLM or TGI metrics scraped, basic dashboard. Sufficient for an initial deployment, blind to hardware-rooted issues. Move to level 2 by adding DCGM and pod-to-GPU mapping.

Level 2: engine plus GPU correlated. Most pragmatic teams stop here. Resolves 70 % of incidents in practice. Move to level 3 when multi-tenant pressure starts and when latency complaints exceed throughput complaints.

Level 3: distributed tracing with GenAI semconv. Per-request visibility, exemplar-driven debugging, tenant-aware SLOs. Required at scale. Move to level 4 for regulated workloads and HPC fabrics.

Level 4: kernel and fabric depth. eBPF policies in alerting paths, NCCL and InfiniBand observability, audit-grade logging with retention policies, confidential computing where applicable. Required for regulated industries, sovereign deployments and large-scale training-adjacent serving.

Move one level at a time. Skipping levels produces dashboards no one trusts.

Where to go next

Three topics deserve their own articles:

  1. KV cache observability: eviction, fragmentation, swap. Native metrics, stress experiments, mitigations.
  2. NCCL and tensor parallelism: observing inter-GPU flows and finding the collective that stalls the batch.
  3. Securing an inference server: attack surface, eBPF detection, sandboxing, AI Act audit trail.

The right implementation order in production:

  1. Inference engine metrics (vLLM, TGI native scrape).
  2. GPU metrics (DCGM exporter).
  3. Distributed tracing with OTel GenAI semconv.
  4. Structured logs with trace_id and request_id.
  5. Business and cost layer.
  6. eBPF policies for security and runtime observability.
  7. NCCL and cuPTI on demand for hard-to-reproduce issues.

Starting with layers 1 and 2 alone resolves most of the incidents observed in production. Everything above that compounds value once the base is solid.

Corrections and operational war stories welcome.

LeetCode Solution: 12. Integer to Roman

Unraveling the Mystery of Roman Numerals: A LeetCode Journey (Problem 12. Integer to Roman)

Hey LeetCoders and aspiring developers! 👋 Today, we’re taking a trip back in time to ancient Rome… well, almost! We’re tackling LeetCode problem 12: “Integer to Roman.” This problem asks us to convert a standard integer into its Roman numeral representation. It sounds simple, but Roman numerals have some quirky rules that make this a fun challenge. Let’s dive in!

🧐 Problem Explanation: What are Roman Numerals Anyway?

Roman numerals use a system of seven symbols, each representing a specific value:

Symbol Value
I 1
V 5
X 10
L 50
C 100
D 500
M 1000

The general idea is to build numbers by adding these symbols. For example:

  • II = 1 + 1 = 2
  • VI = 5 + 1 = 6
  • LX = 50 + 10 = 60
  • MCC = 1000 + 100 + 100 = 1200

But here’s where it gets interesting – there are special “subtractive forms” for numbers that would otherwise require four repeated symbols or just sound clunky:

  • Subtractive Rule: If a smaller value symbol appears before a larger value symbol, it means subtraction.
    • IV = 5 – 1 = 4 (instead of IIII)
    • IX = 10 – 1 = 9 (instead of VIIII)
    • XL = 50 – 10 = 40
    • XC = 100 – 10 = 90
    • CD = 500 – 100 = 400
    • CM = 1000 – 100 = 900

Crucially, the problem states that Roman numerals are formed by converting decimal place values from highest to lowest. This means when converting 3749:

  • You first convert 3000 (MMM)
  • Then 700 (DCC)
  • Then 40 (XL)
  • Then 9 (IX)
  • Combining them gives MMMDCCXLIX.

You can’t do things like IL for 49, because I is not a decimal place lower than L (which is in the tens place, I is in the ones place). 49 is 40 (XL) + 9 (IX). This “decimal place” rule is important for understanding the valid subtractive forms.

Our task is to take an integer num (between 1 and 3999) and return its Roman numeral string representation.

🤔 Intuition: The “Aha!” Moment

When I first look at this, my brain immediately thinks, “Okay, I need to figure out which Roman symbols make up the number.” But with the additive and especially the subtractive rules, a simple if num >= value: add symbol loop might get complicated fast.

The key insight comes from the combination of the specific rules and the examples:

  1. Fixed Symbols & Values: We have a known set of symbol-value pairs.
  2. Greedy Approach: We want to build the Roman numeral from left to right, which means processing the largest possible values first.
  3. Subtractive Forms are Special: CM (900) is one unit in the Roman system, not D then CCCC. Same for CD, XC, XL, IX, IV. These special forms are essentially “preferred” over their additive counterparts.

This leads to the “aha!” moment: If we create a list of all valid Roman numeral values, including the subtractive forms, and sort them from largest to smallest, we can simply go through this list and greedily subtract the largest possible value from our input number until it becomes zero.

For example, if num = 900:

  • If we only considered M=1000, D=500, C=100, we might try to use D (500), leaving 400. Then try C four times. This would be incorrect (DCCCC).
  • But if our list explicitly contains (900, 'CM'), then 900 would be matched directly, giving us CM and the correct result.

✍️ Approach: Step-by-Step Greedy Conversion

Based on our intuition, here’s the plan:

  1. Create a lookup table: We’ll define a list of tuples, where each tuple contains (integer_value, roman_symbol_string). This list is critical:

    • It must include all standard symbols (M, D, C, L, X, V, I).
    • It must also include the special subtractive forms (CM, CD, XC, XL, IX, IV).
    • Crucially, this list must be sorted in descending order of the integer values. This ensures our greedy approach always picks the largest possible Roman numeral component first.

    Our list will look something like this:
    [(1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')]

  2. Initialize result: Start with an empty list or string to build our Roman numeral. A list is generally more efficient for appending in Python, then we’ll join it at the end.

  3. Iterate and subtract: Loop through our value_symbols list (which is sorted largest to smallest):

    • For each (value, symbol) pair:
      • Check how many times this value can fit into our current num. Let’s call this count. count = num // value (integer division).
      • Append the symbol repeated count times to our result list. For example, if num is 3000 and value is 1000, count will be 3, and we’ll append “MMM”.
      • Update num by subtracting count * value from it. This consumes the portion of the number we just converted.
      • We can add an early break if num becomes 0, as there’s nothing left to convert.
  4. Join and return: Once the loop finishes, all parts of num will have been converted. Join the elements in our result list into a single string and return it.

Let’s trace num = 1994 with this approach:

  1. res = [], num = 1994
  2. value_symbols list: [(1000, 'M'), (900, 'CM'), ..., (1, 'I')]
value symbol num (start) count = num // value res.append(symbol * count) num -= count * value num (end)
1000 ‘M’ 1994 1 res = ['M'] 1994 - 1000 994
900 ‘CM’ 994 1 res = ['M', 'CM'] 994 - 900 94
500 ‘D’ 94 0 – – 94
400 ‘CD’ 94 0 – – 94
100 ‘C’ 94 0 – – 94
90 ‘XC’ 94 1 res = ['M', 'CM', 'XC'] 94 - 90 4
50 ‘L’ 4 0 – – 4
40 ‘XL’ 4 0 – – 4
10 ‘X’ 4 0 – – 4
9 ‘IX’ 4 0 – – 4
5 ‘V’ 4 0 – – 4
4 ‘IV’ 4 1 res = ['M', 'CM', 'XC', 'IV'] 4 - 4 0
1 ‘I’ 0 (break loop) – – 0

Finally, ''.join(res) gives us "MCMXCIV", which is the correct answer! This greedy approach, combined with a carefully constructed and ordered lookup table, handles all the Roman numeral rules elegantly.

💻 Code

class Solution:
    def intToRoman(self, num: int) -> str:
        # Define the Roman numeral values and their corresponding symbols.
        # This list is crucial: it must be sorted in descending order of values,
        # and include the special subtractive forms (like 900 for CM)
        # BEFORE their additive components (like 500 for D and 100 for C).
        value_symbols = [
            (1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'),
            (100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'), (10, 'X'),
            (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')
        ]

        # Initialize an empty list to store the Roman numeral characters.
        # Appending to a list and then joining is generally more efficient
        # than repeated string concatenation in Python.
        res = []

        # Iterate through our defined value-symbol pairs.
        for value, symbol in value_symbols:
            # If num has become 0, we've converted the entire number,
            # so we can break early.
            if num == 0:
                break

            # Calculate how many times the current 'value' fits into 'num'.
            # E.g., if num = 3000 and value = 1000, count = 3.
            count = num // value

            # Append the 'symbol' repeated 'count' times to our result list.
            # E.g., if count = 3 and symbol = 'M', it appends 'MMM'.
            res.append(symbol * count)

            # Subtract the converted portion from num.
            # E.g., num = 3000 - (3 * 1000) = 0.
            num -= count * value

        # Join all the symbols in the list to form the final Roman numeral string.
        return ''.join(res)

⏱️ Time & Space Complexity Analysis

Let’s break down how efficient our solution is:

  • Time Complexity: O(1)

    • The value_symbols list has a fixed size (13 elements).
    • We iterate through this list exactly once.
    • Inside the loop, operations like integer division (//), multiplication (*), list append(), and subtraction (-) take constant time. The string multiplication symbol * count and append are also bounded because the maximum count is small (at most 3 for ‘I’, ‘X’, ‘C’, ‘M’) and the Roman numeral string’s total length for num <= 3999 is very short (e.g., “MMMCMXCIX” for 3999 has 7 characters).
    • Since the number of iterations and the work done in each iteration are bounded by a constant (independent of the input num‘s magnitude, within the given constraints), the overall time complexity is constant.
  • Space Complexity: O(1)

    • The value_symbols list is a fixed-size data structure (13 tuples), so it takes constant space.
    • The res list stores the characters of the resulting Roman numeral string. The maximum length of a Roman numeral for an integer up to 3999 is also very small (e.g., “MMMCMXCIX” has 7 characters).
    • Therefore, the space used is bounded by a constant, leading to O(1) space complexity.

This solution is incredibly efficient because it leverages the fixed and relatively small nature of the Roman numeral system’s rules and symbols.

🎯 Key Takeaways

  • Greedy Approach Power: This problem is a classic example where a greedy approach shines. By always taking the largest possible valid Roman numeral component first, and ensuring your lookup table accounts for special cases (like subtractive forms) in the correct order, you can simplify complex rules.
  • Lookup Tables are Your Friend: When dealing with predefined mappings or rules, a well-structured lookup table (like our value_symbols list) can make your code much cleaner and easier to reason about than a series of nested if/else statements.
  • Order Matters! For greedy algorithms, the order of elements in your lookup table is paramount. Always ensure the largest values (including special combinations) come first.
  • Python String Efficiency: Appending to a list and then using ''.join() is generally more efficient for building strings than repeated string concatenation (+=) in Python, especially for potentially longer strings (though in this specific problem, the string length is so small that the difference would be negligible).

And there you have it! Converting integers to Roman numerals might seem tricky at first, but with a solid understanding of the rules and a well-designed greedy approach, it becomes quite straightforward.

Happy coding!

Author Account: p1Hzd8mRM8
Publishing Time: 2026-05-21 17:05:20

🚀 A Beginner’s First Look at Project IDX: Secure Coding from Day One

This is a submission for the Google I/O Writing Challenge

Hey everyone! 👋 Hima Kartikeya here!

I just finished my Class 10 ICSE board exams and I am getting ready to start my polytechnic diploma journey in Andhra Pradesh. My absolute dream is to build a career in Cyber Security, but to keep my creative side active, I’m also a small-scale indie game developer.

Right now, my programming baseline is mastering loops in Python and C, alongside basic Java concepts. While watching the Google I/O 2026 keynotes and going through the developer documentations, one specific release completely blew me away: the massive new upgrades to Google Project IDX.

As a beginner who cares about security, I don’t just want an IDE that helps me write code faster—I want a workspace that helps me write code safer. Here is my perspective on why Project IDX is a game-changer for student developers.

🌐 The Magic of Isolated Cloud Sandboxing

When you are learning low-level languages like C, managing memory manually is a massive hurdle. One wrong line of code can trigger a logic error, cause a memory leak, or create an accidental infinite loop that freezes your entire system.

The Google I/O announcements highlighted Project IDX’s advanced, browser-based virtualized Nix environments.

  • Why it matters to a beginner: It means I can test risky code, write complex loops, and build game scripts in an environment that is entirely separated from my actual computer hardware. If I accidentally create a code block that mimics a Denial of Service (DoS) attack or causes a memory crash, it happens safely inside Google’s cloud sandbox without putting my own machine at risk. It turns the workspace into a safe playground for testing security limits.

🧠 Built-In Security Guardrails: Shifting Security Left

What really caught my eye in the I/O 2026 livestream sessions was how Project IDX is integrating smarter AI-driven security linting right into the workspace.

In my indie game dev practice, I realized how easy it is to leave variables unprotected, allowing a player to easily tamper with game data (like editing health or coin values in memory). The new code-assistance features in IDX don’t just autocomplete your loops; they actively flag potential vulnerabilities as you type.

If my code blindly trusts client-side input or lacks a proper validation check, the workspace alerts me before I deploy. This teaches a student like me the most important rule of modern cyber security: Security isn’t something you patch onto a project at the very end; it belongs to the creator from line one.

💡 My Critique & Takeaway

A lot of the hype around modern developer tools focuses on pure speed and corporate automation. But for students, beginners, and indie creators, the entry barrier to setting up a secure, properly configured local environment is incredibly high.

By making a powerful, cloud-sandboxed environment completely accessible through a browser tab, Google is democratizing secure development. My only critique is that I want to see even deeper integrations for local game engine compilers in the cloud workspace moving forward!

Stepping into my diploma journey, I won’t just be using textbooks to learn security theory. I’ll be spinning up workspaces in Project IDX to actively break and defend my own code in real time.

Over to the Community:
For the seasoned developers and security experts out there: Have you experimented with Project IDX yet? What is your favorite workspace tool for safely testing untrusted code or debugging complex loops? Let’s talk in the comments! 🚀👇

Team Topologies for DevOps: A Practical Implementation Guide

Most engineering organisations do not fail because their developers are untalented.

They fail because their communication structures, ownership boundaries, and operational dependencies create friction that compounds over time.

A deployment takes three weeks because four teams must approve it. A platform team becomes a ticket queue instead of a product team. Stream-aligned teams spend more time negotiating dependencies than shipping software. Cognitive overload silently accumulates until incident frequency rises and delivery velocity collapses.

These are not tooling problems.

They are topology problems.

The framework introduced in the book Team Topologies by Matthew Skelton and Manuel Pais provides one of the clearest operational models for designing engineering organisations around flow rather than hierarchy.

The core idea is deceptively simple

Optimise team structures for fast, sustainable software delivery.

This article explains how to apply Team Topologies in practice, identify the organisational anti-patterns slowing your DevOps initiatives, and implement structural changes that improve delivery speed without creating organisational chaos.

Why Team Structure Matters in DevOps

DevOps is often described as a tooling movement.

It is not.

It is fundamentally a sociotechnical systems discipline.

Tooling matters. Automation matters. CI/CD matters.

But organisational communication paths ultimately determine delivery speed.

Conway’s Law famously states:

Organisations design systems that mirror their communication structures.

Meaning:

  • Fragmented teams create fragmented systems
  • Bottlenecked organisations create bottlenecked architectures
  • High-friction communication creates high-friction delivery

Team Topologies provides a practical framework for reducing those organisational bottlenecks systematically.

The 4 Team Types

The Team Topologies model defines four fundamental team types.

Each exists to solve a distinct operational problem.

1. Stream-Aligned Teams

These are the primary delivery teams.

A stream-aligned team owns a flow of business value end-to-end.

Examples:

  • Payments platform
  • Customer onboarding
  • Mobile checkout
  • Recommendation engine

The key principle:

Single team → owns service lifecycle completely

Including:

  • Development
  • Deployment
  • Operations
  • Monitoring
  • Incident response

Characteristics of Strong Stream-Aligned Teams

Healthy stream-aligned teams typically:

  • Deploy independently
  • Own production support
  • Minimise external dependencies
  • Have clear business alignment
  • Operate autonomously

Example structure

Team: Payments
Ownership:
- Payment API
- Fraud checks
- Transaction database
- Deployment pipelines
- Monitoring dashboards

This dramatically reduces coordination overhead.

Warning Signs

Stream-aligned teams fail when:

  • Too many systems are owned
  • Multiple domains are mixed together
  • External dependencies dominate delivery
  • Teams lack operational authority

The result is cognitive overload.

2. Enabling Teams

Enabling teams exist to help other teams improve capabilities.

Not to permanently do the work for them.

Examples:

  • Kubernetes adoption team
  • SRE coaching team
  • Security enablement team
  • Observability specialists

Their role is temporary acceleration.

Not long-term ownership.

Healthy Enabling Team Behaviour

Good enabling teams:

  • Teach
  • Coach
  • Pair
  • Document
  • Reduce friction
  • Transfer knowledge

Bad enabling teams become outsourced implementation departments.

That destroys scalability.

Example: Kubernetes Enablement

Good pattern:

Enabling Team:
- Creates templates
- Runs workshops
- Helps first deployments
- Coaches incident response

Bad pattern

Every Kubernetes deployment requires enabling team intervention forever

That becomes another bottleneck.

3. Complicated Subsystem Teams

Some domains require deep specialist expertise.

Examples:

  • ML inference systems
  • Real-time video encoding
  • Cryptography engines
  • High-frequency trading systems

These are cognitively dense domains unsuitable for broad ownership.

Dedicated specialist teams reduce complexity exposure for the rest of the organisation.

Why This Team Type Exists

Without complicated subsystem teams

Every stream-aligned team
↓
Must understand advanced specialist systems

This overwhelms cognitive capacity rapidly.

Example

A recommendation-engine ML platform might require:

  • Tensor optimisation
  • GPU scheduling
  • Feature stores
  • Embedding pipelines

That expertise does not belong inside every product team.

4. Platform Teams

Platform teams build internal developer platforms.

Their mission

Reduce cognitive load for stream-aligned teams.

Platform teams should operate like product teams.

Not internal ticket queues.

Platform Team Responsibilities

Typical responsibilities:

  • CI/CD systems
  • Kubernetes platforms
  • Observability tooling
  • Secrets management
  • Golden deployment paths
  • Infrastructure templates

Platform-as-a-Product

This concept is critical.

A healthy platform team provides

Self-service capabilities

Not manual intervention.

Good platform

Developer clicks button → environment created

Bad platform

Developer opens Jira ticket → waits 2 weeks

The 3 Interaction Modes

The framework also defines three interaction patterns between teams.

These interaction modes are enormously important operationally.

1. Collaboration Mode

Temporary close cooperation between teams.

Used for:

New capability adoption
Complex integrations
Discovery work

Example

Payments Team ↔ Platform Team

Working together to implement service mesh adoption.

The Key Word: Temporary

Permanent collaboration indicates unclear boundaries.

Collaboration mode should end eventually.

Otherwise dependency chains become permanent.

2. X-as-a-Service Mode

One team provides services consumed independently by others.

This is the desired long-term state for platform teams.

Example

Platform Team → Kubernetes Platform

Consumed self-service by product teams.

Minimal synchronous interaction required.

Signs Your Platform Interface Is Healthy

Healthy X-as-a-Service characteristics:

  • Well documented
  • Self-service
  • Stable APIs
  • Clear support boundaries
  • Minimal tickets required

3. Facilitating Mode

Used by enabling teams.

Purpose

Teach capability
Not own capability

Examples:

  • Security workshops
  • Incident response coaching
  • Terraform migration guidance

Facilitating mode transfers knowledge intentionally.

Assessing Your Current Topology: The 6 Key Questions

Most organisations already feel their topology pain intuitively.

This framework helps diagnose it systematically.

Question 1: How Many Teams Are Required for a Deployment?

If the answer exceeds three consistently

Flow efficiency is already degraded.

Question 2: Are Platform Teams Productive or Ticket-Driven?

Platform teams buried in support queues are usually under-designed.

Question 3: Is Production Ownership Clear?

During incidents

Who owns this?

Should never require debate.

Question 4: How Much Cognitive Load Exists Per Team?

Too many technologies, domains, or dependencies create delivery paralysis.

Question 5: How Often Are Teams Waiting on Other Teams?

Dependency-heavy organisations slow exponentially as headcount grows.

Question 6: Are Teams Optimised Around Technology or Business Flow?

Technology-aligned teams often create excessive handoffs.

Business-stream alignment improves delivery velocity dramatically.

Cognitive Load Assessment Framework

Example survey structure

COGNITIVE_LOAD_SURVEY = {
    "domain_complexity": {
        "question": "How well does the team understand the business domain?",
        "red_flag": "< 3"
    },

    "technology_breadth": {
        "question": "How many distinct technologies are maintained?",
        "red_flag": "> 5"
    },

    "dependency_count": {
        "question": "How many teams are required per sprint?",
        "red_flag": "> 3"
    }
}

This kind of lightweight operational telemetry is surprisingly valuable.

The Most Common Team Topologies Anti-Patterns

Most engineering organisations fail in recognisable ways.

The same patterns appear repeatedly.

Anti-Pattern 1: The Shared Services Team Bottleneck

Classic example

Shared DevOps Team

Responsible for:

  • CI/CD
  • Kubernetes
  • Terraform
  • Monitoring
  • Networking
  • Security
  • Deployments

For every product team.

Result

Centralised dependency bottleneck

Symptoms:

  • Long ticket queues
  • Slow onboarding
  • Deployment delays
  • Platform burnout

The Real Cost

Shared services teams often become

Organisational rate limiters

Every engineering initiative slows behind them.

Better Model

Replace shared services with:

  • Stream-aligned ownership
  • Self-service platforms
  • Enabling teams
  • Platform-as-product

Anti-Pattern 2: Platform Teams Without a Defined Interface

Many platform teams say

"We provide Kubernetes."

But what does that actually mean operationally?

Healthy platforms define:

  • APIs
  • Golden paths
  • Support models
  • Service expectations
  • Onboarding flows

Without interfaces

Platform becomes tribal knowledge.

Anti-Pattern 3: Enabling Teams That Never Stop Enabling

Enabling teams should create independence.

Not permanent dependency.

Danger signs:

  • Teams require constant coaching forever
  • Knowledge transfer never completes
  • Enablement becomes embedded implementation

At that point the enabling team has failed structurally.

Anti-Pattern 4: Cognitive Load Mismatches

This is one of the most damaging failure modes.

Teams own too much simultaneously:

  • Multiple languages
  • Multiple databases
  • Infrastructure
  • Security
  • CI/CD
  • ML systems
  • Distributed systems complexity

Eventually

Incident frequency rises
Delivery speed drops
Burnout accelerates

Measuring Cognitive Load

Indicators include

Signal Warning Threshold
Technologies maintained > 5
Teams depended on > 3
Incident ambiguity Frequent
Deployment complexity High
Documentation quality Poor

Cognitive overload is usually visible before collapse occurs.

Planning a Topology Change

Topology redesign is organisational surgery.

Done poorly, it creates chaos.

Done carefully, it dramatically improves flow.

Step 1: Identify Friction Points

Start with:

  • Deployment delays
  • Dependency bottlenecks
  • Ticket queues
  • Incident ownership confusion
  • Platform dissatisfaction

Map flow disruptions explicitly.

Step 2: Reduce Team Dependencies

Optimise for

Independent delivery capability

Dependency reduction is usually the highest-ROI organisational improvement.

Step 3: Define Platform Interfaces

Every platform capability should answer:

  • Who uses this?
  • How is it consumed?
  • Is it self-service?
  • What are support expectations?

Step 4: Transition Gradually

Never reorganise everything simultaneously.

Recommended approach

Pilot topology
↓
Measure outcomes
↓
Expand incrementally

Organisational stability matters.

Measuring the Impact

Topology changes should produce measurable improvements.

Delivery Metrics

Track:

Metric Why It Matters
Deployment frequency Measures flow
Lead time Measures delivery friction
MTTR Measures operational clarity
Change failure rate Measures stability

These align closely with DORA metrics.

Cognitive Load Surveys

Run quarterly.

Example

if red_flags >= 3:
    print("Urgent restructuring required")

Even lightweight surveys reveal structural problems surprisingly well.

Platform Satisfaction Scores

Ask stream-aligned teams

How frictionless is the platform?

This single question often exposes platform dysfunction rapidly.

Example Topology Transformation

Before

Developers
↓
Shared DevOps Team
↓
Infrastructure Team
↓
Security Team

Heavy coordination overhead.

Slow deployments.

Unclear ownership.

After

Stream-Aligned Teams
        ↓
Self-Service Platform
        ↓
Enabling Teams

Much faster flow.

Reduced dependencies.

Improved operational autonomy.

Common Mistakes During Team Topologies Adoption

Mistake 1: Renaming Teams Without Changing Responsibilities

Changing titles changes nothing operationally.

Mistake 2: Treating Platform Teams as Infrastructure Operations

Platform teams should optimise developer experience.

Not merely manage Kubernetes clusters.

Mistake 3: Ignoring Cognitive Load

More ownership is not always better.

Mistake 4: Measuring Utilisation Instead of Flow

Highly utilised teams often create slower organisations overall.

Flow efficiency matters more.

Recommended Organisational Architecture

Healthy modern engineering organisations increasingly resemble

Stream-Aligned Teams
        ↓
Platform-as-a-Service
        ↓
Enabling Teams
        ↓
Specialist Subsystem Teams

This structure scales operationally far better than traditional siloed models.

Team Topologies matters because software delivery problems are rarely just technical.

They are organisational.

The framework gives engineering leaders a practical vocabulary for understanding why certain DevOps transformations stall despite heavy investment in tooling and automation.

The most successful organisations consistently optimise for.

Fast flow
Low cognitive load
Clear ownership
Self-service platforms
Minimal dependencies

And those outcomes emerge not from organisational theory alone, but from deliberate topology design.

Because ultimately:

The architecture of your systems
reflects the architecture of your teams.

Always

Seven Contradictions Shaped an Architecture.

Cloud security has seven structural contradictions. Every vendor treats them as trade-offs — improve one side, accept the other gets worse. TRIZ says trade-offs are engineering failures. Contradictions can be resolved. All seven resolved to the same architecture.

Every cloud security tool makes trade-offs:

  • “We give you flexibility, but you’ll get more misconfigurations.”
  • “We check before deployment, but it slows your engineers down.”
  • “We scan continuously, but the results aren’t reproducible.”

Trade-offs feel reasonable. They’re not. They’re engineering failures — situations where improving one property worsens another because the framing of the problem is wrong.

There’s a methodology — 80 years old, derived from analyzing 200,000 patents — that says: don’t trade off. Resolve the contradiction. Keep both properties. The trick is splitting what was previously one thing into two — one that keeps the desirable property, one that absorbs the opposite.

I applied that methodology to cloud security. Seven contradictions were resolved to produce the architecture. Every contradiction resolved to the same three components. That convergence is the validation and the reason the resulting product is 1,030 lines of code that didn’t need to be rewritten.

Contradictions vs trade-offs

The distinction matters:

Trade-off Contradiction resolved
Accept that improving X worsens Y Keep both X and Y — separate them in time, space, or scope
“More flexibility means more misconfigurations” Flexibility and safety coexist when the flexible thing (configuration) is separated from the safe thing (invariant evaluation of the result)

A trade-off accepts the framing. A resolution changes the framing. The methodology — TRIZ, by Genrich Altshuller — provides the discipline for finding which framing change resolves which contradiction.

The seven contradictions

Four months of analysis before writing code produced seven structural contradictions in cloud security. Each is a pair of desirable properties that the industry treats as mutually exclusive:

1. Flexibility vs Safety

We want engineers to build anything. But more flexibility produces more misconfigurations.

What every tool does: Add a scanner with a checklist. The checklist grows. Flexibility doesn’t shrink. The contradiction persists.

Resolution: Configuration stays flexible — Terraform, CDK, Pulumi can produce anything. The result is evaluated against invariants. Flexibility lives in the authoring. Safety lives in the evaluation. They’re separated.

2. Speed vs Correctness

We want engineers to ship fast. But fast shipping produces insecure configurations.

What every tool does: Add another CI check. Engineers learn to bypass with // skipcq and nosec. The check becomes a speed bump, not a safety net.

Resolution: The invariant evaluation runs in milliseconds. The engineer’s velocity is unaffected. The security architect writes the control once. Every subsequent evaluation is automatic. Speed and correctness operate on different clocks — neither blocks the other.

3. Decentralization vs Control

We want decentralized ownership (each team manages their own infrastructure). But decentralization produces inconsistency.

What every tool does: Add a central policy registry. The registry becomes the bottleneck teams route around.

Resolution: The invariant catalog is centrally declared (one canonical set of controls). The snapshots are decentrally produced (each team collects their own). The evaluation runs at the edge — wherever the snapshot is. Central authority over WHAT must hold. Decentralized responsibility for collecting evidence.

4. Human Judgment vs Reliability

We want human contextual judgment (this role needs broad permissions for the migration). But human judgment is inconsistent and irreproducible.

What every tool does: Add severity scores and ML-based triage. The ranking is opaque. The queue is unchanged. The human still decides.

Resolution: The judgment goes into the invariant when it’s authored — the security engineer encodes “PHI buckets must never be reachable by anonymous principals” once. Subsequent evaluations are mechanical. Zero judgment at evaluation time. The judgment was upstream, in the control definition. The evaluation is downstream, deterministic.

5. Expressiveness vs Complexity

We want expressive IaC (conditional resources, dynamic blocks, modules calling modules). But expressiveness produces complexity where errors hide.

What every tool does: Add a static analyzer for the IaC source. The analyzer can’t see what the IaC produces at apply-time — it analyzes the source, not the result.

Resolution: Evaluate the PRODUCED STATE, not the source code. The snapshot is the JSON representation of what Terraform/CDK/Pulumi produced after plan or apply. The IaC source can be as expressive as needed. The snapshot reduces all that expressiveness to typed facts the evaluator can read. Expressiveness lives in the source. Verifiability lives in the snapshot.

6. Dynamic Systems vs Stable Security

We want dynamic infrastructure (auto-scaling, ephemeral, managed services). But dynamic infrastructure breaks static security assumptions.

What every tool does: Make the security controls dynamic too — continuous scanning, runtime agents, real-time detection. Trades adaptability/predictability for responsiveness/reproducibility. The new contradiction is worse: same infrastructure, different results on different days.

Resolution: Keep the controls STATIC. Make the OBSERVATIONS dynamic. The invariant (“anonymous users must not reach PHI data”) doesn’t change when the infrastructure changes. The snapshot captures the infrastructure at a point in time. The evaluation reruns the static invariant against the dynamic snapshot. Deterministic, reproducible, composable.

This is the most consequential resolution in the set — and the only one where the textbook TRIZ principle (Dynamization — make the static thing dynamic) was INVERTED. Every vendor followed the textbook. The inversion produced the opposite architecture — and it’s the reason the product is deterministic where competitors aren’t.

7. Granular Permissions vs Manageability

We want fine-grained IAM permissions (least privilege). But 17,000 IAM actions produce an unmanageable rule set.

What every tool does: Add a policy generator. Generated policies are accurate today, stale tomorrow. The complexity moves from “write the policy” to “maintain the generator.”

Resolution: Humans declare role intent at the category level (“this is a read-only data role”). The control catalog encodes which permission patterns match which intent. The 17,000 IAM actions stay fine-grained — the manageability lives at the intent layer above them. An intermediary between human-readable intent and machine-readable permissions.

The four physical contradictions underneath

Underneath the seven technical contradictions sit four deeper ones — statements where a single thing must have opposite properties:

Physical contradiction Resolution
Configurations must be flexible AND restricted Configuration stays flexible. Invariants restrict the result.
Humans must configure AND not configure Humans declare intent. Automation produces the configuration.
IaC must allow freedom AND restrict freedom IaC is unrestricted. The snapshot of its output is evaluated.
Cloud must be dynamic AND static Cloud stays dynamic. Invariants stay static. Snapshots bridge them.

Every resolution has the same shape: split the thing that was previously one into two. One piece keeps the desirable property. The other absorbs the opposite. The split happens at a different point each time (source vs result, intent vs enforcement, control vs observation) but the move is the same: segmentation.

The convergence

The seven contradictions resolved and kept landing on the same three components:

The three components are:

  1. A fixed declarative artifact that states a property that must hold → the invariant (YAML control).
  2. A mutable observational artifact that captures the world at a point in time → the snapshot (JSON observation).
  3. A deterministic evaluator that runs the first against the second → the engine (CEL predicate evaluation).

Resolving the seven contradictions shaped the architecture.

The convergence is the validation. If the seven contradictions had resolved to seven different architectures, the analysis would have decomposed the problem into seven sub-problems but missed the structural one. The convergence proves the seven are symptoms of one shape underneath and the three-component architecture is that shape.

The nine inventive principles

The TRIZ contradiction matrix suggested specific principles for each contradiction pair. Nine principles did work across all seven:

Principle What it does Where it appears
Segmentation Split one thing into two Invariant/snapshot split. Intent/enforcement split. Central catalog/edge collection split.
Taking Out Remove the unsafe element Safe defaults. Remediation guidance.
Prior Counteraction Neutralize danger before it emerges Judgment encoded in the control predicate at authoring time — not at triage time.
Preliminary Action Act before the dangerous state appears CI gate blocks pre-merge. Preflight evaluation before deployment.
Mitigation in Advance Guardrails that prevent failure even when mistakes happen Compound chains. Ghost-reference detection.
Dynamization (inverted) Dynamize the snapshot, not the control Static controls, dynamic observations. Every vendor did the opposite.
Intermediary Glue layer between two incompatible levels Intent tags between human-readable role descriptions and machine-readable IAM actions.
Self-service Automated execution without human queue Deterministic evaluation with exit codes. No triage queue. No human interpretation.
Parameter Changes Operate at a different level Evaluate the produced STATE, not the source CODE.

The same principles appearing across multiple contradictions is the signature of an architecture with high internal cohesion. The same moves do work across different problem dimensions — because the problem dimensions are symptoms of one underlying structure.

What the industry accepts as trade-offs

Every item is a trade-off the industry accepts. Every item is a contradiction that can be resolved:

Industry trade-off Contradiction Resolution
“More flexibility means more misconfigurations” Flexibility vs Safety Evaluate the result, not the source
“Faster shipping means less security” Speed vs Correctness Evaluate in milliseconds, not in sprints
“Decentralization means inconsistency” Decentralization vs Control Central invariants, decentralized evidence
“Human judgment means irreproducibility” Judgment vs Reliability Judgment upstream in the control, evaluation downstream and mechanical
“Expressive IaC means hidden errors” Expressiveness vs Complexity Evaluate the snapshot, not the source
“Dynamic infrastructure means brittle security” Dynamic vs Static Static invariants, dynamic observations
“Fine-grained permissions means unmanageable rules” Precision vs Manageability Intent layer above the permission layer

Seven trade-offs the industry treats as laws of nature. Seven contradictions that are resolvable with known techniques. The techniques are 80 years old. The application to cloud security is new.

Why convergence matters for builders

If you’re facing multiple contradictions in your own domain and each one seems to need a different solution, you haven’t found the structural contradiction yet. The surface contradictions are symptoms. The structural contradiction is underneath them.

The test: do your resolutions converge?

If seven contradictions resolve to seven different architectures, you’ve decomposed the problem into seven sub-problems. Each sub-solution adds a component. The result is a complex system with seven moving parts.

If seven contradictions resolve to one architecture, you’ve found the shape underneath. The single architecture is simpler than seven sub-solutions. It’s more coherent. And it’s more likely to be correct — because the convergence itself is evidence that you’ve identified the structural problem, not just the symptoms.

Four months of analysis. Seven contradictions named. Nine inventive principles applied. One architecture produced. 1,030 lines of kernel code. No rewrites.

The thinking was the foundation. The code was the proof.

The three-component architecture — invariant (2,650 YAML controls), snapshot (obs.v0.1 JSON), evaluator (1,030-line CEL predicate engine) — is implemented in Stave, an open-source Risk Reasoner. Seven contradictions resolved. One architecture. Every conclusion deterministic, traceable, and provable. Try it: bash examples/demo-ai-security/run.sh

Telemedicine in Venezuela: A Technical Guide for Clinics in 2026

In Venezuela, telemedicine is no longer a futuristic promise. It’s an operational tool for handling clinical follow-ups, expanding coverage, and reducing friction in a system where distance, time, and connectivity still create very real barriers.
When a patient in Barinas needs to see a specialist in Caracas, the options are usually travel or wait — and waiting often means months. In many cases, though, what that patient actually needs is a follow-up, a symptom check, or a second opinion that could be resolved remotely in 20 minutes — if the clinic has the right setup.
The problem isn’t lack of interest. The problem is that most telemedicine software was designed for ideal conditions: stable connections, modern devices, uninterrupted electricity, and patients comfortable installing apps without friction. That’s not Venezuela’s reality.
This guide starts from the actual constraints of the local market and builds from there.

What a Clinic Actually Needs

Before evaluating platforms or signing contracts, there are three variables that determine whether a telemedicine system will work in a Venezuelan clinic or be abandoned within weeks: real connectivity, operational continuity, and integration with the electronic health record.

Minimum Bandwidth by Consultation Type
Connectivity in Venezuela has improved in recent years, but it remains uneven across cities, regions, and carriers. This means you shouldn’t design your system for the best-case scenario — you need to design for the performance you can sustain during peak hours and real-world conditions.

The practical recommendation: design your telemedicine system to work well at SD video and degrade gracefully to audio or text when the network demands it. In Venezuela, a platform that requires HD by default will generate more abandonment than value.
Power and Continuity
Connectivity isn’t the only risk. A power outage mid-consultation can disrupt clinical flow, damage the patient experience, and force physicians to redo administrative work. Any clinic implementing telemedicine should have UPS units at critical workstations, contingency protocols, and a platform that allows resuming a session without losing data already captured during the call.
Patient Devices
Venezuela’s smartphone penetration is broad but highly heterogeneous. In practice, you can’t assume all patients have the same phone model, the same OS version, or the same comfort level installing applications.
The safest approach is a browser-based solution — one that doesn’t require the patient to download software or create an account just to join a consultation. That extra step is where adoption drops immediately.

Platforms That Actually Work
There’s no single perfect platform. The right choice depends on consultation type, the clinic’s technical capacity, and how deeply you need to integrate with your practice management system or EHR.

WhatsApp Business as an Operational Layer
WhatsApp Business is the lowest-friction channel for appointment confirmation, reminders, initial triage, and post-consultation follow-up. It shouldn’t be seen as a replacement for video consultations — it’s an operational layer that reduces no-shows and improves patient communication.
Its advantage isn’t technical, it’s adoption: the patient already uses it, already understands it, and doesn’t need to learn anything new. That simplicity has real value in the Venezuelan market.
In clinics that automate appointment reminders, no-show rates typically drop significantly — literature on appointment automation reports reductions of 35–40%, and in some environments up to 40–50%.

Self-Hosted Jitsi Meet
Jitsi Meet is a compelling option for clinics that want technical control and infrastructure independence. Being self-hosted means no dependency on external vendors and greater control over data, configuration, and operational policies.
Jitsi’s strength isn’t perfection — it’s flexibility. It works well as the foundation of an in-house strategy, as long as the clinic has minimal technical administration capacity.

Doxy.me
Doxy.me is particularly useful when patient simplicity is the priority. Its virtual waiting room model and link-based access reduce friction, making it attractive for follow-up consultations and less digitally fluent patients.
Its main limitation for some Venezuelan clinics isn’t functional — it’s data governance and dependency on external infrastructure. For some use cases it’s sufficient; for others, a more controlled solution makes more sense.

What NOT to Use — and Why
Zoom and Microsoft Teams are excellent tools for business meetings. They should not be the default choice for clinical telemedicine in Venezuela. They’re designed for general video calls, not integrated clinical workflows, and they add unnecessary friction for patients in day-to-day use.
There are also two practical objections that rarely come up in a sales demo. The first is privacy: handling sensitive clinical data requires more care than a standard meeting. The second is clinical continuity — if the consultation doesn’t end up recorded in the EHR, the remote visit becomes isolated from the rest of the patient’s history.
Zoom and Teams can work in specific scenarios, but they shouldn’t be the foundation of a telemedicine strategy for any Venezuelan clinic looking to scale with order.

Integrating Telemedicine with the EHR
The real value of telemedicine appears when the remote consultation lives inside the patient’s complete clinical history. Without that, the video call becomes an isolated tool — not part of the clinical system.

Minimum Viable Integration Flow
The simplest way to integrate telemedicine with the EHR:

  1. The telemedicine platform fires an event when the consultation ends.
  2. An intermediary service transforms that data into the format your system uses.
  3. The EHR saves the visit as a virtual consultation inside the patient’s clinical timeline.

With this flow, the physician who sees that patient three months later has full context — in-person and remote visits on the same timeline. That’s real clinical continuity.

Minimum Data to Capture
For the record to have actual clinical value, the platform should store at minimum:

  • Patient identifier
  • Date and time of the consultation
  • Treating physician
  • Reason for consultation
  • Diagnostic impression
  • Prescriptions or instructions given
  • Next clinical action

That’s a solid foundation. Everything else can be added in later phases.

Webhooks and HL7 FHIR
If your EHR supports HL7 FHIR R4, integration can be done using standards rather than custom one-off builds for each platform combination. In that model, the consultation maps to an Encounter resource, and the virtual visit is classified using the appropriate HL7 ActCode for telehealth.
This matters because it prevents you from rebuilding integrations as the system grows, and opens the door to connecting lab, pharmacy, referrals, and multi-site networks in the future.

Legal Framework
Venezuela doesn’t yet have a unified, fully mature regulatory framework for telemedicine. Institutional references and plans exist, but technical and operational regulation remains more limited than in more developed markets.
What is clear: the treating physician maintains full clinical responsibility in a remote consultation, and the patient’s informed consent for the virtual modality must be documented. Clinics should also maintain records of how they store, protect, and process clinical data — especially if they treat diaspora patients or operate under international insurance agreements.

Where to Start
Sequence matters. The safest path for a Venezuelan clinic is to move in phases, validate each step, and resist the temptation to build everything at once.
Phase 1 — WhatsApp Business
Start with appointment confirmation, automated reminders, and post-consultation follow-up. Measure response rates, no-show reduction, and time saved in coordination. If those numbers improve, you have a real foundation.
Phase 2 — Video Consultation
Add a video platform for follow-ups or specific specialties. The goal isn’t to “have telemedicine” — it’s to confirm that patients connect, complete the consultation, and navigate the flow without significant support.
Phase 3 — EHR Integration
Once the remote flow is working and validated, connect the platform to the clinical history and practice management system. Webhook first. Standard mapping second. Full automated record creation after that.

What Comes Next
Telemedicine isn’t the destination. It’s the entry point to a digital health system where remote consultations, clinical records, lab results, and pharmacy data share useful information in real time.
Venezuela has an interesting opportunity: it can build directly on modern standards without the burden of expensive legacy systems. Clinics that start building this infrastructure now will have a competitive advantage in three years that late movers won’t be able to replicate quickly.

At Code by Meléndez, we work with Venezuelan clinics on exactly this kind of project — from architecture to the first functional patient flow.

Read the original article in Spanish: Telemedicina en Venezuela: guía técnica para clínicas en 2026

SSO, SAML, OIDC, and SCIM: What Actually Happens When You Click “Sign in with Google”

You click “Sign in with Google” on GitHub. Seconds later, you are in. No new account. No new password.

That felt simple. The system behind it is not.

This article breaks down the four protocols that power modern identity management: SSO, SAML, OIDC, and SCIM. You will understand what each one does, where they differ, and how they work together in real companies like Netflix and Airbnb.

The Problem These Protocols Solve

Before SSO, every app had its own login database. A new employee joining a company needed separate accounts in Gmail, Slack, Jira, Salesforce, and every other tool. IT teams created each account by hand. When someone left, accounts often lingered for weeks, open to misuse.

Three problems emerged:

  • Users carried dozens of passwords, and reused them
  • IT spent hours on account creation and resets
  • Every separate login system was a separate attack surface

SSO solves this by centralizing authentication. You log in once to a trusted Identity Provider (IDP) and every other app trusts that login. One credential. One place to enforce MFA. One place to revoke access.

But SSO is not a single technology. It is a goal. SAML, OIDC, and SCIM are the tools that achieve it.

The Three Main Players in Any SSO Flow

Before going into protocols, know these three roles:

User: The person trying to access a service.

Identity Provider (IDP): The system that verifies the user’s identity. Examples: Okta, Azure AD, Google Workspace.

Service Provider (SP): The application the user wants to access. Examples: Salesforce, GitHub, Slack.

The IDP and SP are separate systems. They trust each other through a formal agreement called federation. Federation lets your company’s IDP issue a kind of digital passport, and every connected app checks that passport at the door.

SAML: The Enterprise Veteran

SAML stands for Security Assertion Markup Language. It is an open standard that has powered enterprise SSO since 2002.

SAML Flow Diagram

Here is the step-by-step SAML flow:

  1. You try to access Salesforce (the Service Provider)
  2. Salesforce redirects you to your IDP (say, Okta)
  3. Okta asks for your credentials and verifies them, including MFA
  4. Okta generates a SAML Assertion, an XML document containing your identity, roles, and group memberships
  5. Okta sends that assertion back to Salesforce
  6. Salesforce verifies the IDP’s digital signature on the assertion and grants access

The assertion is the key artifact. It carries signed proof of who you are and what roles you hold. Salesforce does not store your password. It trusts Okta’s signature.

Where SAML excels:

  • Legacy enterprise apps (Salesforce, Workday, ServiceNow)
  • Organizations with strict compliance requirements (healthcare, government)
  • Web browser-based SSO flows

Where SAML struggles:

  • Mobile apps, which do not handle XML-based browser redirects well
  • APIs and microservices that prefer JSON
  • Modern single-page applications

Real example: A hospital network uses SAML to give doctors access to patient record systems, billing tools, and scheduling software with one login. Auditors get a single log of every access event. SAML’s verbose XML is worth the overhead when the data is sensitive and regulated.

OIDC: The Modern API-Friendly Option

OpenID Connect, or OIDC, is built on top of OAuth 2.0. OAuth handles authorization (what an app is allowed to do on your behalf). OIDC adds the missing authentication layer (who you actually are).

When you click “Sign in with Google” on GitHub, you are using OIDC.

Here is the flow:

  1. You click “Sign in with Google” on GitHub
  2. GitHub redirects you to Google’s authorization server
  3. Google asks for your credentials
  4. Google sends back two tokens: an Access Token and an ID Token
  5. The ID Token is a JWT (JSON Web Token) signed by Google, containing your identity
  6. GitHub reads the JWT, verifies Google’s signature, and logs you in

The JWT looks like this (decoded):

{
  "sub": "1234567890",
  "name": "Jane Doe",
  "email": "[email protected]",
  "iss": "https://accounts.google.com",
  "exp": 1716560000
}

This is lightweight, JSON-based, and works seamlessly in mobile apps, SPAs, and API calls.

SAML vs OIDC at a glance:

Feature SAML OIDC
Format XML JSON (JWT)
Age 2002 2014
Best for Enterprise web apps Mobile, APIs, SPAs
Complexity Higher Lower
Adoption Legacy enterprise Modern cloud apps

Real example: Spotify’s mobile app uses OIDC when you log in with Facebook. The app receives a JWT, verifies it, and creates your session without ever touching Facebook’s password system.

SCIM: The Account Lifecycle Manager

SAML and OIDC handle authentication. They answer: who is this user?

SCIM (System for Cross-domain Identity Management) answers a different question: does this user’s account exist in the first place, and does it have the right attributes?

SCIM is a REST API specification. If your app implements SCIM endpoints, an IDP like Okta or Azure AD can automatically:

  • Create accounts when someone joins
  • Update attributes when someone changes teams
  • Deactivate accounts when someone leaves

Here is what SCIM looks like in practice:

Create a new user (IDP calls your app):

POST /scim/v2/Users
{
  "userName": "[email protected]",
  "name": { "givenName": "Jane", "familyName": "Doe" },
  "department": "Engineering",
  "active": true
}

Deactivate a user on departure:

PATCH /scim/v2/Users/12345
{
  "Operations": [{ "op": "replace", "path": "active", "value": false }]
}

When Jane joins the company, her IDP account triggers SCIM calls to Slack, Zoom, GitHub, and Jira. Her accounts appear before her first day. When Jane leaves, one deactivation in the IDP removes access everywhere within minutes.

Real example: Netflix built an internal “People Service” that ingests identity data from Workday and Azure AD, then distributes it to hundreds of internal tools via SCIM and internal APIs. Without this, onboarding a new engineer would require tickets to a dozen different teams.

How the Three Protocols Work Together

These are not competing standards. Each solves a different layer of the same problem.

HR System
    |
    | (triggers onboarding event)
    v
Identity Provider (Okta / Azure AD)
    |
    |-- SCIM --> Creates accounts in Slack, GitHub, Zoom, Salesforce
    |
    |-- SAML --> Authenticates users in legacy enterprise apps
    |
    |-- OIDC --> Authenticates users in modern apps and APIs

A concrete scenario:

  1. A new engineer joins. HR updates their record.
  2. Azure AD receives the event and uses SCIM to create accounts in GitHub, Slack, and Jira with the correct team permissions overnight.
  3. The next morning, she logs into Azure AD once.
  4. Azure AD uses OIDC to authenticate her into the internal developer portal.
  5. Azure AD uses SAML to authenticate her into the legacy finance system.
  6. She accesses everything without logging in again.
  7. Six months later, she leaves. Azure AD deactivates her account. SCIM removes her access from every connected app within minutes. SAML and OIDC stop issuing tokens for her identity.

The Pros and Cons

SSO (the goal):

Pros:

  • One set of credentials for all apps
  • Centralized MFA enforcement
  • Single audit log for access events
  • Faster onboarding and offboarding

Cons:

  • If the IDP goes down, users lose access to everything
  • Requires careful configuration between IDP and each service provider
  • Single point of compromise if the IDP is breached without strong MFA

SAML:

Pros:

  • Mature, battle-tested in enterprises
  • Carries rich attributes (roles, groups, departments)
  • Strong support in legacy enterprise apps

Cons:

  • XML is verbose and complex to debug
  • Poor fit for mobile apps and APIs
  • Implementation errors (like signature validation mistakes) are a common source of vulnerabilities

OIDC:

Pros:

  • Lightweight JSON tokens
  • Works everywhere: web, mobile, APIs
  • Built on OAuth 2.0, which developers already know

Cons:

  • JWT misconfiguration (like skipping signature validation) creates security holes
  • Token expiry and refresh logic requires careful handling

SCIM:

Pros:

  • Eliminates manual account management
  • Reduces orphaned accounts (a major security risk)
  • Standardized, so IDPs support it out of the box

Cons:

  • Not every app supports SCIM endpoints
  • Centralizing SCIM in a sync service adds architectural complexity
  • Debugging sync failures across many apps is non-trivial

Where This Fits in the Bigger Picture: IAM

SSO, SAML, OIDC, and SCIM are components of a broader discipline called Identity and Access Management (IAM).

IAM covers:

  • Authentication (AuthN): Proving who you are. SAML, OIDC, MFA.
  • Authorization (AuthZ): Controlling what you can do. RBAC, ABAC, policy engines like OPA.
  • Provisioning: Managing account lifecycles. SCIM.
  • Federation: Trust between organizations. SAML, OIDC across org boundaries.

A practical distinction worth knowing: AWS IAM is Amazon’s system for controlling access to AWS resources (S3, EC2, Lambda). It is not a general enterprise IDP. Most companies use Okta or Azure AD as their central IDP and connect to AWS through federation and role mappings.

When a developer logs into AWS through Okta SSO, Okta authenticates them (via SAML or OIDC) and passes their identity to AWS IAM, which then decides what resources they can access.

A Real Security Consideration: The SSO Risk Trade-Off

SSO reduces password sprawl and improves user experience. But it concentrates risk.

If an attacker compromises an employee’s IDP credentials and MFA, they gain access to every federated application at once. This is why:

  • MFA on the IDP is not optional, it is the primary defense layer
  • Conditional access policies (blocking logins from unrecognized devices or locations) are critical
  • Session timeouts should be enforced at the IDP level
  • Privileged accounts should use hardware security keys (FIDO2), not TOTP codes

The 2023 MGM Resorts breach reportedly started with social engineering an IT help desk into resetting an Okta account. The attacker then moved laterally across MGM’s systems using the SSO trust relationships. SSO amplified the blast radius of a single credential compromise.

The lesson: SSO is more secure than password sprawl when configured correctly. The IDP is the crown jewel. Protect it accordingly.

Summary

  • SAML handles authentication for enterprise web apps using XML assertions. Mature, verbose, and essential for legacy systems.
  • OIDC handles authentication for modern apps and APIs using lightweight JSON tokens. Built on OAuth 2.0.
  • SCIM handles account lifecycle management. It creates, updates, and removes user accounts automatically across all your apps.
  • SSO is the goal. SAML and OIDC are the protocols that deliver it. SCIM keeps the underlying accounts in sync.
  • IAM is the full framework. SSO is one piece of it.

If you are building a product today, start with OIDC for authentication and implement SCIM endpoints if you want enterprise customers (they will ask for it). If you are connecting to legacy enterprise systems, SAML support is non-negotiable.

Found this useful? Share it with your team. If you want a deeper breakdown of OAuth 2.0 flows, JWT security, or RBAC versus ABAC authorization models, drop a comment below.

Mastering Next.js 16 Server Actions & Forms: The Future of Full-Stack React | Muhammad Arslan

The landscape of web development is shifting. In 2026, the boundary between the client and the server has all but vanished. Next.js 16 Server Actions represent the pinnacle of this evolution, allowing developers to write mutations that are as simple as calling a local function, yet powerful enough to handle complex full-stack workflows.

By Muhammad Arslan

Senior Full Stack Engineer & Node.js Specialist

In this guide, we will move beyond the basics. We’ll explore how to leverage React 19 Hooks (useActionState, useOptimistic) and Next.js 16 internals to build forms that are not only type-safe and secure but also provide a “zero-latency” experience for users.

1. The Paradigm Shift: From APIs to Actions

For years, the standard way to handle form submissions in React was:

  1. Create an API route (e.g., /api/submit).
  2. Manage loading, error, and data states with multiple useState hooks.
  3. Manually fetch the endpoint on the client.
  4. Handle response serialization and revalidation.

Server Actions eliminate this entire middle layer. They are asynchronous functions that run on the server, marked with the 'use server' directive. When you call an action, Next.js handles the POST request, serialization, and revalidation for you—automatically.

Comparison: Why the change matters

Feature API Routes (Legacy) Server Actions (Modern)
Boilerplate High (Route, Types, Fetch) Zero (Direct Function)
Client JS Needed Always Optional (Progressive Enhancement)
End-to-end Types Manual / Complex Automatic (Shared Scope)
Security Payload exposure risk Closed-loop server execution

2. Managing Complex Form State with useActionState

React 19 introduced useActionState (replacing the experimental useFormState). It is the primary hook for handling the response of a Server Action within a form.

Instead of managing isLoading and error manually, useActionState consolidates your form lifecycle into a single, predictable state object.

// Optimized by Muhammad Arslan
import { useActionState } from 'react';
import { signUpAction } from './actions';

export default function SignUpForm() {
  const [state, formAction, isPending] = useActionState(signUpAction, { 
    success: false, 
    errors: {} 
  });

  return (
    <form action={formAction} className="glass-container">
      <input name="email" type="email" placeholder="Email" />
      {state.errors.email && <p className="text-red-500">{state.errors.email}</p>}

      <button disabled={isPending}>
        {isPending ? "Creating Account..." : "Sign Up"}
      </button>
    </form>
  );
}

3. Real-Time Feedback with useFormStatus

A common UX pitfall is the lack of feedback during submission. While useActionState gives you isPending, React 19 provides useFormStatus to help you create reusable UI components that “know” their parent form’s status without prop-drilling.

Important Note from Muhammad Arslan: useFormStatus must be used in a child component of the <form>.

// app/components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button 
      type="submit" 
      disabled={pending}
      className={`btn ${pending ? 'opacity-50 cursor-not-allowed' : ''}`}
    >
      {pending ? "Saving..." : "Save Changes"}
    </button>
  );
}

4. Zero-Latency UX with useOptimistic

Users hate waiting. Optimistic Updates allow you to show the result of an action before the server even responds. If the server fails, React automatically rolls back the UI to the previous state.

This pattern is essential for high-engagement features like chat messages or social likes.

'use client';
import { useOptimistic } from 'react';

export function CommentList({ initialComments }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    initialComments,
    (state, newComment: string) => [...state, { content: newComment, id: 'temp' }]
  );

  async function action(formData: FormData) {
    const content = formData.get('content') as string;
    addOptimisticComment(content); // Update UI instantly
    await postComment(formData);    // Run the actual Server Action
  }

  return (
    <div>
      {optimisticComments.map(c => <div key={c.id}>{c.content}</div>)}
      <form action={action}>
        <input name="content" required />
        <button type="submit">Post</button>
      </form>
    </div>
  );
}

5. The “Arslan Security Layer”: Zod + Server Actions

Never trust client-side data. Because Server Actions are just functions, they are vulnerable to malicious payloads if not properly gated. I recommend a “Validation-First” approach using Zod.

// app/actions.ts
'use server';

import { z } from 'zod';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

const schema = z.object({
  email: z.string().email(),
  username: z.string().min(3),
});

export async function updateUser(prevState: any, formData: FormData) {
  const validated = schema.safeParse(Object.fromEntries(formData.entries()));

  if (!validated.success) {
    return { errors: validated.error.flatten().fieldErrors };
  }

  // Pure server-side execution: Secrets are safe here
  await db.user.update({
    where: { id: process.env.CURRENT_USER_ID },
    data: validated.data,
  });

  revalidatePath('/profile');
  return { success: true };
}

6. Closing the Loop: Data Revalidation

One of the most powerful features of Server Actions is their integration with the Next.js Cache. By calling revalidatePath('/dashboard') or revalidateTag('user-data'), you tell Next.js to purge the cache and refetch the data for those specific segments.

This ensures that once your Server Action completes, the user sees the updated data immediately without a manual page refresh.

Conclusion: Why This Matters

Next.js 16 and React 19 have shifted the focus back to the server, but with the responsiveness of a client-side app. By mastering Server Actions, Zod validation, and Optimistic UI, you build applications that are:

  1. More Secure: Sensitive logic never leaves the server.
  2. Faster: Zero API boilerplate and instant UI feedback.
  3. Resilient: Progressive enhancement ensures your forms work even on slow connections.

The web is evolving, and the server is once again the center of that evolution.

What’s your experience with React 19 hooks? Join the discussion and see more of my work at muhammadarslan.codes.