How I Built My Own Laravel Analytics Package (and Almost Didn’t Crash Production)

Why Not Google Analytics? (Or why I love reinventing the wheel)

To be honest, this wasn’t an easy call. I’ve been in development for quite a while and had grown accustomed to GA. What could be simpler: you slap a script with your ID onto the site, and data starts flowing into the analytics console. All that’s left is to wait for the traffic to roll in and then analyze it by country, gender, age groups, and so on.

Yes, that’s how it used to be, but in today’s reality, there are objective reasons to rethink this concept. Let’s be real: hooking up Google Analytics in 2026 is like putting a massive deadbolt on your front door but leaving the keys with your neighbor. Everything seems under control, but the neighbor knows exactly when you came home, what you bought, and why you have a long face. And when someone visits you and wants to stay incognito, the neighbor won’t give them the keys, and they won’t even tell you they stopped by. Their response would be something like: “Nobody came, I never sleep, everything is under control…”. What am I getting at?

Remember the General Data Protection Regulation (GDPR)?

Now, for your analytics to work, you must display a cookie consent banner and get the user’s permission. And that’s where the problem lies. 90% of users don’t accept all cookies—only the strictly necessary ones. And what does that mean? Google Analytics ends up dead in the water. Besides, everyone is sick and tired of these banners. So, if there’s a legal way to ditch them, why not take it?

(To learn how to avoid legal trouble with data protection laws, check out the article GDPR Without the Headache: A Guide for Web Developers in Germany )

The decision to drop GA was made. When I decided to hit the “Eject” button and catapult myself out of the Google ecosystem, I faced a logical question: what would fill the void? Because I still needed the numbers. I started looking at the heavy artillery.

The first candidate was Matomo (formerly Piwik): Probably the most powerful all-in-one machine. It’s like keeping a pet elephant in your backyard. It does everything, the database grows like crazy, but it requires a separate PHP server, MySQL, and constant babysitting. For my small pet projects, it felt like trying to drive nails with a microscope.

The second tool I looked at was Plausible / Fathom: Sleek, modern, and privacy-respecting. But there’s a catch: you either pay a subscription (a questionable investment for a free tool) or you mess around with self-hosted Docker versions, which also eat up a good chunk of your VPS RAM.

I looked at all of this and thought: “Do I really need to spin up an entire infrastructure just to know that 50 people read my article on German taxes yesterday?”. That’s when it hit me: I don’t need a “combine harvester.” I need a tiny, precision scalpel that lives right inside my Laravel application.

I wanted something of my own: lightweight, like a morning espresso, and not asking annoying questions about GDPR. Plus, I was simply curious about who all these people (and bots) were “knocking” on my security tools at oleant.net. Since this blog also needed the same tool, I decided to develop a standalone package that could be easily installed via composer and published openly on Packagist.

For those interested in diving into the code, the package is called oleant/laravel-visit-analytics, compatible with Laravel versions 10/11/12. GitHub link: https://github.com/Oleant-NET/laravel-visit-analytics

The Heart of the System: Middleware and the Magic of the Aftertaste

Which architecture to use? There are various ways to implement this. Fortunately, Laravel has a great Middleware mechanism. I decided to stick with that, but would it slow down the user? They shouldn’t have to care about my overhead. And what if the database goes down or something goes wrong? A 500 error page as the face of the site is definitely not what I was aiming for.

That’s why a crucial decision was made — to use the terminate() method. In a Laravel Middleware, it’s like a polite waiter: he brings you the check and smiles (the handle() method), and only after you’ve already left the restaurant does he go back to wipe the table and log the tip (the terminate() method).

The user has already received their page and is happy, while our server quietly and without rush writes the logs to the database in that moment. Even if something goes wrong, the client still leaves satisfied, and the waiter… just doesn’t get a tip this time. Just kidding, but here’s how it works in practice:

PHP

public function terminate(Request $request, Response $response): void
{
    try {
        // Logic to exclude non-target clients, like Admin etc.
        // …
        // Next, write our visitors to the database
        $this->logVisit($request);
    } catch (Throwable $e) {
        // If the DB takes a nap, we won't wake the user with a 500 error
        Log::error("Analytics failed, but we're keeping our cool: " . $e->getMessage());
    }
}

Lifehack: All analytics code must be wrapped in a try-catch. Believe me, there’s nothing sillier than “crashing” an entire project just because the logger didn’t have enough room for a long User-Agent or some other non-obvious case.

GDPR on the Fly: How Not to Become Public Enemy No. 1
To avoid slapping a banner the size of half the screen saying “We are watching you, bro,” I implemented IP anonymization. We simply trim the last part of the address before it ever touches the database. This anonymizes the user and fully complies with the law. Yet, we can still tell which country, data center, etc., the visit came from. We’ll talk more about data center bots once there’s more data to show.

Back to IP anonymization:

PHP

// Before: 192.168.1.154 -> After: 192.168.1.0

It’s like seeing a crowd in masks: you understand that 5 people showed up, but who among them is your neighbor — you have no clue. The law is satisfied, and so is my conscience.

Payload: Gathering Only the Goodies
Initially, I wanted to write everything that comes in the URL to the database. But then I looked at the Livewire logs, where half the state of the planet is passed in the parameters, and realized — the database would explode. So, I decided to implement filtering based on allowed parameters in the config using array_intersect_key. Now, only what I’ve personally authorized in the config ends up in the logs. Clean, orderly, and zero fluff.

The default set in the package config looks like this:

PHP

'whitelist' => [
    'utm_source',
    'utm_medium',
    'utm_campaign',
    'utm_term',
    'utm_content',
    'ref',
],

But you can, of course, change it by publishing the config in your project first:

Bash

php artisan vendor:publish --tag="visit-analytics-config"

After that, a visit-analytics.php file will appear in your config folder. There, you can not only expand the list of tracked parameters (for example, adding something like page or search) but also specify excluded paths so you don’t turn your database into a dump for admin panel or technical endpoint logs.

What’s Next?

The package started humming, and the data began to flow. I closed my laptop and went to bed, thinking I’d wake up to some visitor charts from a couple of friends. But reality turned out to be much tougher (and more interesting).

In the next episode, we’ll step through the “looking glass”: who are Palo Alto Networks, why do bots read my Terms of Service in 7 seconds, and why Linux + DuckDuckGo is a badge of quality for a visitor?

What is Microsoft E7 and Do You Need It?

If your organization is running AI agents, the answer is probably yes.

Microsoft 365 E7, also called the Frontier Suite, became available on May 1, 2026 at $99/user/month on an annual commitment. It bundles four products into one SKU: Microsoft 365 E5, Microsoft 365 Copilot, the Microsoft Entra ID Suite, and Microsoft Agent 365. Buy them separately and you’re looking at roughly $117/user/month. The bundle saves you about $18 per user, or around 15%, before any CSP promotions apply.

The first three components won’t surprise anyone who’s been watching Microsoft licensing. E5 is the existing enterprise security and productivity foundation. Copilot is the AI assistant most organizations have already been pricing as a standalone add-on since 2024. Entra ID Suite adds the deeper identity governance features – Privileged Identity Management, Identity Protection, lifecycle workflows – that E3/E5 customers typically bolt on separately.

Agent 365 is the piece most IT teams haven’t budgeted for yet. That’s the one worth paying attention to.

Why Agent 365 Changes the Equation
AI agents are already in production at most large organizations, whether IT knows about it or not. IDC projects 1.3 billion AI agents in use by 2028, and 80% of the Fortune 500 are already running Microsoft’s agentic capabilities. The problem isn’t adoption – it’s governance. Agents that spin up without proper oversight become a shadow-IT problem fast: they access data, generate outputs, and operate under policies nobody set.

Agent 365 gives each AI agent an identity in Entra, tracks what it accesses, and enforces your existing compliance and security policies on it the same way those policies apply to people. If you’re running Copilot Studio workflows, Foundry automations, or any autonomous process that touches sensitive data, this is the control plane you need. Without it, you’re essentially running agents with no audit trail.

You can buy Agent 365 as a standalone for $15/user/month. But if you’re already on E5 with Copilot, you’re spending $117/user combined – at which point E7 at $99 just makes more sense.

The Promotions
Through December 31, 2026, Microsoft is offering CSP customers introductory discounts on E7:

  • 10% off with 10+ seats on an annual term (~$89.10/user/month)
  • 15% off with 100+ seats on an annual term (~$84.15/user/month)
  • 15% off with 300+ seats on a three-year term (~$84.15/user/month)

At 100+ seats with the 15% promo, you’re paying about $84/user instead of the $117 you’d pay stitching it together yourself. For a 150-user org, that’s roughly $59,000 in annual savings compared to separate licenses.

Who Should Skip It
E7 isn’t for everyone. If you’re on Business Premium and only want Copilot, the price jump doesn’t work out. If Entra ID P1 covers your identity needs and you have no agents in production or on the roadmap, you’re paying for governance tooling you won’t use. The trap we see most often is organizations upgrading to E7 for Copilot alone, then leaving Agent 365 dormant. If that’s your plan, stay on E5 plus standalone Copilot and revisit at renewal.

But if agents are already running in your environment – or they will be in the next 12 months – E7 is the licensing structure built for it. The governance capability isn’t a nice-to-have at that point. It’s the thing that keeps autonomous AI from becoming your next compliance problem.

Find out more in this in-depth breakdown of M365 E7.

NeuroCalT+ Mental Math

A long time ago, I read a perfect book. I really like that kind of stuff. The main problem I had back then was trying to train my skills. It is so much fun to use this method to solve math problems, but it is absolutely not as fun to create those tasks for yourself. I thought a lot about how to do it and tried several apps, but they didn’t offer what I wanted. I wanted pure math—no gamification, no ads, just examples to solve.

Moreover, I really like multiplication by 5 (even numbers). It is so simple and so impressive when you show someone how you can multiply any number like

## 84848284868682 * 5

in seconds. I am glad I met people who are interested in it as well. The story of Trachtenberg is unique, and that’s a fact.

I decided to make a small app for training, and I found that the most interesting part is multiplication. Maybe I will add some other operations in the future. If you want, you can try it out. It is free, requires no registration, has no annoying ads, and so on—just pure math. I hope you like it!

https://play.google.com/store/apps/details?id=com.milvus.grema

Şipşak özelliği kapatma! İnstagram'da şipşak nasıl ve nereden kapanır?

Şipşak özelliği kapatma! Instagram’ın mesaj kutusuna entegre ettiği “Şipşak” özelliği, kullanıcıların anlık fotoğraflarını hızlı şekilde paylaşmasına imkan tanırken sosyal medya kullanıcılarının gündeminde yer aldı. İnstagram şipşak özelliği kapatma! Snapchat benzeri yapısıyla dikkat çeken sistemde paylaşımlar kısa süre içinde kaybolurken, ekran görüntüsü alınamaması da kullanıcılar arasında merak edilen başlıklar arasında yer aldı.

What did our VPC look like at 22:14 (Tue, 2026-05-20) — building clew, a CLI for navigating AWS Config snapshots

clew data flow: AWS Config snapshot to DuckDB to render/query outputs

It’s 2am. Production is on fire. Someone in the war room asks, “what did the VPC actually look like at 22:14, when this started?”, and the only honest answer is, “give me twenty minutes with jq.”

That moment, repeated enough times, is why I started writing the tool I want to talk about today: clew, a Go CLI that ingests AWS Config Configuration Snapshots into a local DuckDB and renders an interactive topology you can actually read.

▎ https://github.com/nishikawaakira/clew

This post is the story of why it exists and how to use it during an incident.

The problem: “the moment-in-time configuration” is a real artifact you can’t easily see

A scenario you might recognize:

It’s 22:14. Targets behind an ALB start going UnHealthy, but only some of them. CloudWatch shows the symptom but not the cause. People in the war room start floating theories:

  • “Maybe someone tightened a security group rule.”
  • “Maybe a route table was modified and Private Subnet traffic is now going out the wrong gateway.”
  • “Maybe a subnet’s route table association silently changed.”

All plausible. But each of them needs the same piece of evidence: what the topology actually looked like at 22:14, ideally next to what it looked like at 18:00 when things were still fine. The AWS Console only shows you “right now,” which is precisely the moment you don’t care about anymore.

The good news: AWS Config has been quietly dropping a full Configuration Snapshot of every tracked resource into S3 every six hours. The artifact exists. The bad news is that the artifact ships as:

  • A <account>_Config_<region>_ConfigSnapshot_<timestamp>_<snapshot-id>.json.gz file
  • Containing a flat configurationItems array, mixed across resource types
  • Where “this EC2 uses this security group” lives under configuration.networkInterfaces[].groups[].groupId
  • And “this VPC contains these subnets, which contain these instances” is purely implicit — you reconstruct it in your head from the relationships[] field

jq at 2am is a poor incident-response interface. And drawing the VPC topology on paper while pagers are going off is worse.

Existing tooling, and where it fell short for me

I’m not pretending nothing else exists. The usual suspects:

  • AWS Config Aggregator + Advanced Queries (SQL-ish querying from the console)
  • CloudFormation StackSets with drift detection
  • Commercial CMDB / IaC visualizers (Lucidchart’s AWS importer, Hava, etc.)
  • Rolling your own jq library and committing it to the team’s snippet repo

What I couldn’t find was something that hit all of:

  1. Works against an arbitrary historical snapshot (any S3 object, not just “now”)
  2. Local-first, no SaaS round trip — incident artifacts shouldn’t leave the responder’s laptop
  3. Produces a single self-contained HTML file I can paste into a Slack thread or a postmortem doc
  4. Lets me stack multiple snapshots in the same store and compare across time

So I started writing what I wished existed.

What clew does

Three commands, one data model:

clew data flow: AWS Config snapshot to DuckDB to render/query outputs

The DuckDB file holds three tables:

config_items

Stores one row per imported configurationItem, including the original configuration, relationships, tags, and capture_time.

graph_nodes

Stores one row per resource, including placeholder rows for resources referenced by relationships but not yet imported.

graph_edges

Stores resource-to-resource edges extracted from both relationships[] and the configuration body.

clew VPC topology view showing VPC, subnet, EC2, and ENI nodes

The default render --format html output is the part most worth showing off. It produces an interactive topology with compound nodes — VPCs are drawn as outer boxes containing subnet boxes containing EC2 / ENI nodes — the same nesting you’d draw on a whiteboard during incident response, except generated from the snapshot you just imported.

The generated HTML is interactive: you can pan and zoom, click nodes to inspect their type, resource ID, ARN, and placeholder status, toggle edge labels, switch layout direction, and choose between orthogonal and bezier edges.

The file is fully self-contained — copy it anywhere, open it in any browser.

Walkthrough: using clew in incident-response mode

Step 0: Install

# Requires Go 1.24+ and a C compiler — go-duckdb is CGO-bound.
go install github.com/nishikawaakira/[email protected]

This assumes AWS Config is already enabled and delivering configuration snapshots to S3.

Step 1: Pull the snapshot(s) closest to the incident from S3

AWS Config writes keys with unpadded month/day (…/2026/5/20/…), so dancing around BSD-vs-GNU date is a waste of energy. Listing recursively and filtering by name is the safer path:

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=<incident-region>
BUCKET=<your-config-bucket>

aws s3 ls "s3://${BUCKET}/AWSLogs/${ACCOUNT_ID}/Config/${REGION}/" --recursive | grep ConfigSnapshot | sort

# Grab the two snapshots straddling the incident window and `aws s3 cp` them down.

If you need one right now and don’t want to wait for the next scheduled delivery:

aws configservice deliver-config-snapshot --delivery-channel-name default

In my experience, the S3 object usually shows up within 10–60 seconds.

Step 2: Import everything into a single DuckDB

clew import --input snapshot-2026-05-20T22-00.json.gz --db incident.duckdb
clew import --input snapshot-2026-05-20T18-00.json.gz --db incident.duckdb

You can keep stacking snapshots into the same incident.duckdb. config_items.item_id is a PRIMARY KEY of account:region:type:resource_id:captureTime, combined with ON CONFLICT (item_id) DO NOTHING on insert.

The practical guarantees:

  • Re-importing the same snapshot is a true no-op (the inserts collide on item_id).
  • Re-importing a snapshot taken at a different capture time keeps both rows for the same resource — history is preserved, not overwritten.

graph_nodes / graph_edges always reflect the latest state, so the rendered topology is always “the most recent snapshot you imported,” but the historical detail still lives in config_items.

Step 3: Render the topology

clew render --db incident.duckdb --view vpc --format html --output vpc.html
open vpc.html

If you want it for a Slack thread:

clew render --db incident.duckdb --view vpc --format png --output vpc.png

The raster output is fine for a quick share, but it doesn’t scale well to large topologies — once you have more than a couple of dozen resources, the nodes get cramped and labels become hard to read. For anything larger, the interactive HTML is the better artifact to share with the team, because reviewers can open it locally and pan/zoom.

If you want to embed it in a postmortem Markdown:

clew render --db incident.duckdb --view vpc --format mermaid --output vpc.md

Step 4: Zoom in on a suspect

When you have a specific theory — “the ALB stopped reaching this instance” — the global view is too much. query runs a bidirectional BFS (breadth-first search) from one resource:

# Two hops out from a specific instance, as interactive HTML.
clew query --db incident.duckdb --resource-id i-0abc... --depth 2 
 --format html --output i-0abc.html

# Or a text summary in the terminal when you just want to confirm a hunch.
clew query --db incident.duckdb --resource-id sg-12345 --depth 1 --format text

The text format is intentionally boring and grep-friendly:

Nodes:
  - AWS::EC2::Instance/i-0abc...
  - AWS::EC2::SecurityGroup/sg-12345
  - AWS::EC2::Subnet/subnet-pub
  ...
Edges:
  - <node-id> ==[Uses security group]==> <node-id>
  ...

Step 5: Diff across time, with plain SQL

Because every snapshot lands as its own row in config_items, you can ask DuckDB directly:

duckdb incident.duckdb
SELECT capture_time, configuration_json
FROM config_items
WHERE resource_id = 'sg-12345'
ORDER BY capture_time;

Comparing the 22:00 vs. 22:14 configuration_json of a single security group is now a diff between two rows. The whole point of putting the data in a real database was to make the ad-hoc questions stop requiring custom code.

What clew doesn’t do yet

Being honest about scope:

  • Only the vpc view is implemented. The internal model is type-agnostic, so adding iam, org, kms, or network (peering/TGW) views is a matter of writing the type list and the configuration-edge extractors, not a redesign.
  • The HTML output pulls Cytoscape.js / dagre / cytoscape-dagre from a CDN at view time. Useful in 95% of cases, useless on a fully air-gapped laptop. An –embed-js mode using go:embed is the obvious follow-up.
  • No special tuning for very large snapshots. Inserts are straightforward prepared statements. For tens of thousands of resources you’d want the DuckDB Appender API or parallel ingest.
  • Cross-account / cross-region edges only exist if AWS Config explicitly listed them. Reconstructing organization-wide topology needs more wiring (likely an Organizations + Config Aggregator integration).

Why “clew”?

clew is the Old English word for “ball of thread” — and the literal etymological root of the modern word clue. In Greek mythology it’s the thread Ariadne gave Theseus so he could find his way back out of the Labyrinth.

Production AWS environments tend to drift toward labyrinth shape, especially after years of “just one more security group / one more subnet / one more peering connection.” Most of the time it’s fine. Then 2am happens, and the same environment that felt familiar yesterday looks like a maze.

clew is named for the thread you wish you had on the way in.

Try it

go install github.com/nishikawaakira/[email protected]
clew --help

Repository: https://github.com/nishikawaakira/clew (MIT)

If you want to see the output without setting up real AWS Config, the repo ships a testdata/sample_snapshot.json that exercises every piece of the rendering pipeline:

clew import --input testdata/sample_snapshot.json --db /tmp/demo.duckdb
clew render --db /tmp/demo.duckdb --view vpc --format html --output /tmp/demo.html
open /tmp/demo.html

This is still a PoC — there are sharp edges and obvious gaps. Issues, PRs and “here’s the view I actually need” feature requests are all welcome.

Thanks for reading. If this means one fewer engineer is writing jq queries at 2am next month, the project has paid for itself.

Por que os Modelos de IA “Open” Estão Ganhando Espaço em Relação aos Grandes Modelos de Linguagem (LLMs)

Todo mundo está falando sobre ChatGPT, Claude e Gemini. Mas enquanto o mercado corre atrás dos modelos fechados, CTOs e líderes de tecnologia estão silenciosamente migrando para uma estratégia muito mais poderosa: IA open-weight.

Neste artigo, mostro o que quase ninguém fala sobre os bastidores da IA corporativa:

  • o custo real de rodar modelos como Llama em produção,
  • por que muitas empresas estão abandonando o discurso “100% open source”,
  • os riscos ocultos de dependência de fornecedores,
  • soberania digital,
  • segurança de dados,
  • e a verdadeira guerra entre autonomia e conveniência na nova economia da inteligência artificial.

Depois de colocar os números na ponta do lápis em ambientes Nvidia Cloud e GCP, a conclusão foi muito diferente do hype vendido no LinkedIn.

Se você é CTO, arquiteto, engenheiro, fundador ou trabalha com IA corporativa, este conteúdo provavelmente vai mudar sua visão sobre modelos abertos, custos de inferência e estratégia de longo prazo.

Leia o artigo completo

Standing on shoulders: the stack that makes Floci start in ~24ms

Floci starts in 24ms and idles at 13 MiB of RAM.

That number gets attention, and it tends to obscure the real story. It isn’t about one clever optimization. It’s about four open-source projects that have been quietly perfecting their respective jobs for years, stacked on top of each other in a way that makes the whole thing possible.

Floci is a free, MIT-licensed AWS emulator. It speaks the AWS wire protocols on localhost:4566, emulates 45 services, and is verified by 1,925 SDK compatibility tests across Java, Python, Node.js, Go, and Rust. Here’s the stack underneath it.

Netty, the async I/O substrate

Netty has been around since 2004. If you’ve used Elasticsearch, Cassandra, gRPC-Java, Pulsar, Spark, the AWS SDK v2 async client, or Spring WebFlux (via Reactor Netty), you’ve used Netty. Every TCP connection into Floci, every UDP datagram, every connection to a spawned RDS container terminates in a Netty channel pipeline somewhere.

Two decades of hardening by Apple, LinkedIn, Twitter, and basically every JVM-based hyperscaler. The bugs at the protocol-decoder level have been found and fixed. The performance characteristics are extremely well understood. And critically, Netty has been a good citizen of the native-image world. It plays well with GraalVM out of the box.

Vert.x, the reactive toolkit

Vert.x is the layer where Floci uses things directly. It’s Netty with ergonomics: an event loop, a router, protocol-specific APIs for HTTP, DNS, TCP, WebSockets, gRPC, all sharing the same threading model.

Floci uses Vert.x for two things that matter.

The HTTP router. AWS routing is weird. The same TCP endpoint serves REST-XML (S3), REST-JSON, x-amz-json-1.0 and 1.1 (DynamoDB), query-string protocol (older services), and EC2 query protocol. Vert.x’s router gives you full control over dispatch without forcing a single framework’s idea of what a controller looks like.

The embedded DNS server. Floci runs its own authoritative DNS on UDP/53 inside the container, so *.localhost.floci.io resolves to Floci’s container IP on the Docker network. That’s how virtual-hosted S3 (https://my-bucket.s3.localhost.floci.io/key) works from inside spawned Lambda containers with no extra setup. Tractable in Vert.x because HTTP, UDP, and TCP all share infrastructure: same event loop, same lifecycle. About 200 lines.

Quarkus, the framework layer

This is where Floci becomes a coherent application instead of a pile of libraries.

Quarkus does at compile time what traditional frameworks do at runtime: bean discovery, dependency injection wiring, configuration resolution, JAX-RS route registration, Jackson serializer generation. That matters because GraalVM is a closed-world compiler. It has to know at compile time every class that might be loaded and every method that might be called via reflection. Quarkus is engineered from the ground up to give the native compiler that information.

The validating case for Quarkus is Keycloak, which migrated its entire codebase from WildFly to Quarkus in 2023, explicitly citing startup time and memory. When a project of that scale and conservatism makes that move, it’s a strong signal.

GraalVM Mandrel, the native-image compiler

This is the layer that delivers the headline numbers. Ahead-of-time compilation. Closed-world analysis. Dead code elimination. The output is a single statically-linked native executable. No JVM at runtime, no JIT warmup, no classpath scanning at startup.

Metric Floci (native) Typical JVM
Startup ~24 ms 2,000 to 6,000 ms
Idle RAM ~13 MiB 200 to 400 MiB

Mandrel is Red Hat’s downstream of GraalVM, tuned for Quarkus workloads, with release cadence aligned to Quarkus. An underrated piece of supply-chain hygiene.

Thank you

Floci wouldn’t exist without any of these layers. Forty-five AWS services is a lot of surface area for one maintainer. The reason it’s tractable is that the four layers below don’t need maintenance. They’re production-hardened by people I’ll never meet, and I get to focus on AWS wire-protocol fidelity, which is the interesting part of an emulator.

Thank you to the Netty, Vert.x, Quarkus, and GraalVM teams. Floci stands on your shoulders.

github.com/floci-io/floci

Security Controls in Enterprise RAG: Keys, Audit Logs, and the Hierarchy That Prevents Role Elevation

Enterprise RAG — A practitioner’s build log | Post 5 of 6

A knowledge search system for internal documents carries a specific security obligation: it must not make it easier to access restricted information than going to the document source directly. If an employee can ask a question and receive an answer that reflects finance data they are not authorized to see, the system has introduced a new attack surface that did not exist before.

The security design in Enterprise RAG addresses this through a hierarchy of controls — not a single mechanism, but a layered set that each address a distinct failure point. This post documents what each control does, the tradeoff it accepts, and what remains explicitly unimplemented.

Control 1: API key role binding — preventing request-body role elevation

The query endpoint accepts a user_role parameter in the request body. For unauthenticated local use, this is acceptable. In any shared or externally accessible environment, it is a security problem: a caller who knows the role parameter name can claim any role and retrieve documents outside their authorization.

The control is API key role binding. When a request includes an X-API-Key header, the role context for retrieval is derived from the key holder’s registered role — not from the request body. The request body role is ignored entirely.

User registers → POST /auth/register (role assigned at registration)
Admin creates key → POST /api-keys (key is bound to user’s role)
Query with key → POST /query + X-API-Key:
Role used for retrieval: key holder’s registered role (request body role ignored)

This closes the role elevation path for authenticated callers. A key issued to a user with role: employee cannot retrieve finance documents even if the caller submits user_role: finance in the request body.

API keys are stored as SHA-256 hashes. The raw key is returned once at creation and never again. If a key is lost, it must be revoked and reissued — the stored hash cannot be reversed to recover the original value.

Control 2: Key revocation — making access removal immediate

API key revocation (POST /api-keys/{api_key_id}/revoke) removes the stored hash. A revoked key is rejected by POST /query on the next request — there is no grace period, no cache to drain, no session to expire.

This is operationally important for two scenarios: an employee departure and a compromised credential. In both cases, the recovery action is immediate revocation rather than waiting for a session timeout or token expiry.

The revocation endpoint requires the ADMIN_TOKEN when management protection is enabled, which means the revocation action itself is authenticated. An unauthorized caller cannot revoke another user’s key.

Control 3: Management endpoint protection — separating operational from query access

A class of endpoints — ingestion, user registration, key creation, key listing, audit log access, and evaluation runs — are administrative by nature. In any shared or hosted environment, these endpoints must not be accessible without authentication.

When ADMIN_TOKEN is set, these endpoints require X-Admin-Token in the request header:

- `POST /ingest`
- `POST /auth/register`
- `POST /api-keys`, `GET /api-keys`, `POST /api-keys/{api_key_id}/revoke`
- `GET /audit-logs`
- `POST /eval/run`

The query endpoint (POST /query) is governed separately by API key authentication. Query access and management access use different credentials with different scopes. A leaked query key does not grant management access. A leaked admin token does not include the role context of any specific user.

Control 4: Audit logging — making every administrative action traceable

Every management action writes a record to audit_logs: which action was taken, when, and by which admin credential. The audit log is readable through GET /audit-logs with admin authentication.

The current scope of audit logging covers administrative actions. Query logs — which record the question asked, the role used, the citations returned, and the RBAC-blocked chunk count — are stored separately in the query log table and accessible through the dashboard.

Together, these two logs answer the questions a security review will ask: who ingested documents, when were keys created or revoked, what was queried by which role, and what was blocked.

Control 5: Security headers and CORS — default-on, not opt-in

Security headers are enabled by default (SECURITY_HEADERS_ENABLED=true in the base configuration). CORS origins are configured explicitly through CORS_ORIGINS — no wildcard default.

These are baseline controls that cost nothing and prevent a class of browser-based attacks. An API that stores internal document citations should not allow cross-origin requests from arbitrary origins.

For Azure deployment, the CORS origin list should enumerate only the dashboard Container App URL and any internal tools that call the query API directly.

What is not yet implemented

Entra ID and OIDC role derivation from token claims. The AUTH_PROVIDER=entra and AUTH_PROVIDER=oidc configuration paths are implemented and validate bearer JWTs against issuer, audience, expiration, and JWKS signing keys. Role mapping reads from roles, groups, or role token claims and defaults to employee when no role claim is present. End-to-end validation requires a live Azure tenant — it is not testable in the local environment.

Tenant isolation for multi-organization deployments. The current implementation assumes a single organization. Multi-tenant deployment — where organization A’s documents are completely isolated from organization B — requires additional data model work and is a documented production consideration.

PII classification for ingested documents. The included reference documents are synthetic. Production ingestion should classify documents for PII content and apply explicit retention policies for prompts and generated answers before storing them.

Distributed rate limiting. The current in-memory rate limiter (RATE_LIMIT_PER_MINUTE) works correctly for single-instance deployments. Multi-instance production deployments require Redis-backed or API gateway rate limiting.

The security posture in plain terms

Enterprise RAG is designed for internal deployment by an engineering team with control over the document corpus, the user registry, and the infrastructure. The controls are appropriate for that context. The gaps — multi-tenant isolation, production-grade PII classification, distributed rate limiting — are appropriate for a larger managed deployment and are documented rather than hidden.

Deploying this system to a shared or externally accessible environment without setting ADMIN_TOKEN is a configuration error, not an implementation gap. The controls are present. The operator must activate them.

Next engineering step

Enable ADMIN_TOKEN in your local .env, attempt to call POST /ingest without the token, and verify the endpoint returns a 401. Then call GET /audit-logs with the admin token and confirm the rejected attempt was logged. That sequence validates that management protection is enforced and that audit logging is capturing the right events.

One question for you

For internal tools that handle restricted documents, do you separate query credentials from management credentials? If a query key were compromised, could an attacker use it to ingest new documents or access the audit log?

Final post in this series: Deployment readiness — what is running locally, what the Azure path requires, and an honest list of what needs to be in place before this system handles real internal documents in production.

Building a Voice AI Agent in Italian with ElevenLabs + n8n: Lessons From 200 Live Bookings/Month

I deployed a voice AI agent in 7 Italian restaurants. It handles 200 bookings a month, in native Italian, for €87/month total cost. Here is what worked, what broke, and the exact stack that ships in production.

No marketing fluff. Real numbers from 60 days of live deployment, the latency we hit, the edge cases that broke our agent, and the three scenarios where I now refuse to deploy voice AI even when the client begs.

Why Italian Voice AI Is a Different Beast

If you’ve shipped an English voice agent, here’s what you should expect to break when you port the same architecture to Italian.

Latency tolerance is lower. Italian conversation has shorter pauses between turns. The 1.5s response time that feels “fast” in English starts to feel awkward in Italian above 1.2s. Native speakers begin to repeat themselves or check if you’re still there. We had to drop our agent’s first-response target from 1.5s to 1.0s before user satisfaction stopped degrading.

Regional accent variance is huge. A 60-year-old Roman saying “addò sta er ristorante” is grammatically Italian but ASR-wise it might as well be Catalan. We tested ElevenLabs ASR on three sample populations (Roman elderly, Neapolitan middle-aged, Northern under-30): WER ranged from 4% (Northern under-30) to 19% (Roman elderly). For restaurants in Rome’s historic center, we had to add a clarification fallback at the first failed parse, not the third.

Cultural patterns matter for prompt design. Italian restaurant calls open with extended pleasantries (“buongiorno, scusi il disturbo, volevo solo sapere se per stasera…”). English-trained prompts that try to short-circuit straight to “what’s your reservation?” feel rude. We added a 1-2 turn “social warmup” phase before pushing toward intent collection. Booking completion rate went from 71% to 89%.

English-trained TTS sounds robotic in Italian. This isn’t just opinion. Blind test, n=50 Italian native speakers, two voice models: Italian-trained ElevenLabs voice scored 4.4/5 naturalness, English-trained voice doing TTS in Italian scored 2.1/5. The latter was correctly identified as AI 78% of the time within the first 10 seconds.

The Stack (in 4 Components)

After testing Vapi, Retell, Bland and a custom Whisper + GPT-4o + OpenAI TTS pipeline, here’s what shipped to production:

1. ElevenLabs Conversational AI — voice synthesis + ASR + intent routing in one product. Italian native voices (“Bianca”, custom-cloned), conversation flow handled in their dashboard. Cost: $0.08/min on the Creator plan. Why I picked this over a custom pipeline: managing Whisper + GPT-4o + TTS as three separate services added ~400ms latency and required a state machine I didn’t want to maintain.

2. n8n self-hosted on a Hetzner CX22 (€5/month) — the orchestration layer. Webhook from ElevenLabs hits n8n on intent recognized (“book_table”, “ask_menu”, “modify_reservation”), n8n does the actual DB work (Postgres lookup, availability check, write reservation), responds back to the agent with structured data.

3. PostgreSQL on Supabase (free tier handles all 7 restaurants comfortably) — restaurant menu, tables, opening hours, reservations, customer history. Schema is boring on purpose: 6 tables, 22 columns total.

4. Twilio for Italian VoIP — €1/month per phone number, €0.013/min inbound. Yes, we evaluated Vonage, Plivo and Bandwidth. Twilio’s Italian numbers had the best call quality and the only support team that actually answered Italian dial issues within 24h.

Total monthly cost per restaurant: €12.50 (~€5 Hetzner shared across 7 restaurants + €1 Twilio + ~€6.50 ElevenLabs minutes).

Alternatives I rejected after testing:

  • Vapi: comparable quality, 30% more expensive at our volume
  • Retell: better latency on English, noticeably worse Italian voice quality (synthetic accent)
  • Bland: cost-attractive but conversation quality not yet there for Italian
  • Custom pipeline (Whisper + GPT-4o + OpenAI TTS): 200-400ms latency penalty, broke my “one neck to choke” rule

The Knowledge Base Problem (And How I Solved It)

Restaurant menus change daily. A static system prompt with “today’s menu” baked in goes stale in 24h. The naive solution — paste the new menu into the prompt every morning — breaks at scale (7 restaurants, 3 staff who don’t want to log into a dashboard).

The workflow that works:

Every day at 06:00:
1. Cron in n8n triggers
2. Pulls menu PDF from each restaurant's shared Google Drive folder
3. Sends PDF to Mistral OCR (free tier, 1GB/month, more than enough for menus)
4. Parses returned text into structured JSON (dishes, prices, allergens)
5. Upserts into Postgres `menu_items` table with `valid_from = today`
6. Marks yesterday's menu `valid_to = today`
7. Pings ElevenLabs webhook to invalidate runtime cache

40 lines of n8n nodes. Staff drop the PDF into Drive when they want, the agent picks it up next morning. Zero manual sync.

The trick that took me 3 iterations to find: don’t load the full menu into the prompt context. The agent calls a menu_lookup tool function only when the user asks about food. Keeps context lean (cheaper), keeps responses focused, lets us A/B test menu phrasing without touching the agent prompt.

Real Numbers After 60 Days

Aggregate data across 7 restaurants, May 2026:

  • Booking completion rate (without human handoff): 90%
  • Average first-response time: 4.2 seconds (full conversation initiation, not first token)
  • User AI-detection rate in first 30 seconds: 6% (94% don’t realize it’s AI initially; blind test n=50)
  • Average call duration: 1m 47s (vs 2m 32s for human staff — same booking, faster collection)
  • Peak load handled: 12 simultaneous calls (Saturday 19:00-21:30)
  • Total monthly bookings handled by the agent across 7 sites: 1,403

The 10% that fails:

  • Large group bookings (>15 people) — agent escalates to “we’ll call you back to confirm”
  • Complaints about previous experience — agent transfers to manager
  • Custom requests outside menu (“can you make this dish without gluten?”) — explicit fallback to staff

These three categories cover ~95% of all handoffs. I list them in the agent’s system prompt as “always escalate” cases. The remaining 5% are genuinely unclassifiable edge cases (drunk callers, kids playing with phones, sales calls from suppliers).

When Voice AI Is the Wrong Answer

I get 4-5 inbound requests per week now. I refuse roughly 30% of them. Here’s when:

Scenario 1: under 25 calls/week. The ROI math doesn’t work. Below that volume, the time the owner spends learning the system, training staff to interpret the dashboard, and dealing with the inevitable first-month edge cases costs more than the time saved. Pay a part-time receptionist.

Scenario 2: elderly-skewed clientele (>70% over 65). Voice AI works fine with seniors who are tech-comfortable, but if the bulk of your callers are 70+ and not tech-comfortable, the conversational friction is real. We had one trattoria in Pescara where 80% of bookings were elderly regulars. Booking completion rate stayed at 62% even after 4 weeks of prompt tuning. We turned the agent off.

Scenario 3: highly consultative calls. Anything where the value is in extended human conversation — legal intake, medical triage, financial advice — should stay human for ethical reasons before practical ones. Voice AI can take a callback request. It shouldn’t take a substantive professional consultation.

Saying no to these is how I keep the 90% completion rate on the agents that do ship.

Closing

If you want the full Sofia case study (architecture diagrams, n8n workflow JSON, the actual ElevenLabs prompt template, screenshots of the dashboard), I documented everything publicly at andreasisofo.it/sofia-ristoranti.

I’m Andrea Sisofo, freelance ex-BBDO based in Rome. I build voice AI agents in Italian for SMBs. Reach me at andreasisofo.it or on LinkedIn.

Open to questions in the comments.

News roundup: May 21, 2026 — Camunda, Hadrian, Devart

Camunda announces ProcessOS, an agentic operating system for AI-first enterprise transformation Agentic orchestration platform provider has announced  ProcessOS, a new intelligence layer that the company says leads into “the great re-engineering” of processes and concurrent workflows. According to the company, to move past mere AI task assistance, companies will need to rethink about which tasks … continue reading

The post News roundup: May 21, 2026 — Camunda, Hadrian, Devart appeared first on SD Times.