Back to Directory/Cloud Providers

GenHTTP Lambda

Write, deploy and host small C# web services and sites at a public address.

Cloud ProvidersTypeScriptv0.1.0

GenHTTP Lambda

Write a C# snippet that returns a GenHTTP IHandler, press deploy, and get a public URL that serves it. The snippet is compiled with Roslyn at runtime and the resulting handler is hosted by the same GenHTTP server that serves this application.

return Inline.Create()
             .Get(() => "Hello from my lambda!");

Quick start

docker compose up --build

The application is then available at http://localhost:8080/. Its data - the SQLite database, the stored code and the lambda workspaces - lives in a named volume mounted at /data.

Developing locally

Two processes: the .NET server, and Vite serving the frontend with hot reload.

# the server, on http://localhost:8080/
dotnet run --project src/GenHTTP.Lambda

# the frontend, on http://localhost:5173/ (proxies /api and /lambda to the server)
cd src/Frontend && npm install && npm run dev

Work against http://localhost:5173/ while developing. The server on its own answers with a placeholder page until a frontend has been built into its web root, which is what npm run build does:

cd src/Frontend && npm run build   # writes src/GenHTTP.Lambda/wwwroot

Build and test everything the way CI does:

dotnet build GenHTTP.Lambda.slnx -c Release
dotnet test GenHTTP.Lambda.slnx -c Release

The tests compile and execute real lambdas against a real server on a random port, each against its own temporary data directory.

Routes

RouteServes
/the landing page
/editor/createthe creation assistant
/editor/:privateKeythe editor for one lambda
/lambda/:publicKeythe deployed handler
any path, at a lambda's own domainthe deployed handler of a premium lambda with that domain
/api/v1/everything the editor calls, see below
/mcpthe same, for agents
/adminevery lambda on the server, for whoever runs it

API

Browsable at /api/v1/scalar/. A lambda is addressed by its editor key, which is also what authorizes the call. Things that can be listed, read, created or changed are resources; what is a process rather than a thing is a verb in the path.

EndpointDoes
POST /lambdascreates a lambda
GET / PATCH / DELETE /lambdas/:privateKeyreads, changes (its key), removes it
GET /lambdas/:privateKey/exportthe lambda as a runnable project (zip)
GET / POST /lambdas/:privateKey/versionslists versions, saves a new one (optionally with specification and change)
GET /lambdas/:privateKey/versions/:versionreads one version
GET /lambdas/:privateKey/versions/:version/zipone version's files as a zip
POST /lambdas/:privateKey/versions/zipsaves a zip of all files as a new version
POST /lambdas/:privateKey/versions/changeschanges some files of the newest version
GET /lambdas/:privateKey/deploymentwhat is online, and until when
POST /lambdas/:privateKey/deployment/start / stopputs a version online, takes it off
GET /lambdas/:privateKey/deployment/historyevery stretch it was online, and what ended it
GET /lambdas/:privateKey/summarythe dashboard: state, traffic, problems, storage
GET /lambdas/:privateKey/trafficrequests by minute and quarter hour, statuses, paths
GET /lambdas/:privateKey/logsits own log, followed with ?since=; no visitor addresses
GET /lambdas/:privateKey/agentthe agent's change of it - under way, or the last one - and how many are left today
POST /lambdas/:privateKey/agent/start / stopasks the agent for a change, stops it
GET /lambdas/:privateKey/fileslists the workspace
GET / PUT / DELETE /lambdas/:privateKey/files/:pathone file, its path encoded (a%2Fb.txt), as base64 up to 32 MB
GET / PUT /lambdas/:privateKey/files/:path/contentone file as it is, streamed, however large
PUT /lambdas/:privateKey/folders/:pathmakes a folder
POST /lambdas/:privateKey/code/checkcompiles without saving
POST /lambdas/:privateKey/code/semantics, completions, definitionwhat the editor asks the compiler
GET /keys/:publicKeywhether a key is free, and if not, online
GET /demosthe demos, and the keys to read them with
POST /builds, GET /builds/:idthe text box on /build
GET /systemterms, limits, starters, build agent
GET /telemetry, /logs, /admin/...for whoever runs the installation

Creating a lambda asks what somebody would like to build and offers the demos in those words - "Keep track of things", "Let people sign up" - next to an empty lambda. Picking a demo starts the new lambda as a copy of it, which is theirs to change. The files are in Resources/Templates and listed in TemplateCatalog.

A lambda has two keys. The public one is part of its URL and may be changed; the private one is the editor link and is shown only to whoever created the lambda - anyone holding it can edit, deploy and delete.

A lambda may also return a websocket rather than a document, in any of the three flavours the module offers - Websocket.Functional(), .Reactive() and .Imperative(). The handshake is an ordinary request and is bounded by the execution timeout like any other; everything after it belongs to the connection, which is why a socket may stay open far longer than a lambda is given to answer. FrameType, which the imperative flavour reads off every frame, is one of the names a lambda is given, so no import is needed.

The editor says when both free tier timers run out: a hint under the public URL for the deployment, and a chip beside the buttons for the lambda itself, which opens an explanation of how each one is extended.

Editing and deploying are separate: saving creates a version, deploying picks one (the latest by default) and makes it live. A lambda has at most one deployment at a time, and older versions stay available to deploy again.

Changing a lambda by asking

Where the installation runs the build agent, the editor has a Change section: the owner says what should be different, and the same agent that builds things on /build changes the lambda - it reads the code and the history, changes only what was asked with change_code, fixes what does not compile, and puts the result online as a new version. Switched off, it saves the version for the owner to look at and deploy themselves.

The section shows it happen: what the agent says it is doing, each tool it calls with what came of it (the version saved, errors from the compiler, the version online), and a clock against its time limit. Afterwards it offers the difference to the version before, the address, and putting the previous version back. The change is followed by the frame of the editor rather than the section, so the sidebar marks it and the lambda is read again when it ends wherever the owner is; and it is kept by the agent under the lambda, so a reload, a second tab or a redeploy of the server finds it where it got to.

It shares the queue and the daily allowance of /build, one change of a lambda runs at a time, and it can be stopped - whatever it saved stays a version. A change runs without create_lambda, and its editor key travels in the brief inside the build container, never in a log line.

How it is put together

A single .NET 11 project hosted by GenHTTP.Full.Ioxide, wired up in Application.cs and divided into services that the API resources talk to through interfaces:

  • Meta (Services/Meta) - lambdas and their versions, the only component that speaks to the database. Its public surface is DTOs, mapped by hand.
  • Storage (Services/Storage) - the code itself, on the file system, one file per version. Never in the database.
  • Workspace (Services/Workspace) - the private directory of a lambda, reached from the editor. The same directory the generated Workspace class writes to from inside a lambda, under the same limits, so a file put there by hand behaves like one the lambda wrote itself. Only the room it takes is limited - 256 MB for most lambdas and 2 GB for a premium one, in any number of files of any size - counted in blocks of 4 KB as the disk counts it, so an empty file or a folder is not free. The quota is compiled into the lambda, so moving it to another tier builds it again on its next request.
  • Deployment (Services/Deployment) - wraps a snippet in a method body, compiles it with Roslyn, loads the assembly and calls PrepareAsync() on the resulting handler. Compiled once, then cached.
  • Execution (Services/Execution) - an IHandler, not a web service: it looks up the handler for a request and runs it.
  • Protection (Services/Protection) - concerns in front of execution that resolve the lambda, rate limit per client and cap concurrency.
  • Background (Services/Background) - a small scheduler that undeploys free tier lambdas a day after their last deployment and removes them after 30 days without a save.

The editor asks the compiler what the code means rather than guessing from how it is spelled. Services/Deployment/Compilation/SemanticClassifier.cs says what each name is, so a type, a method, a parameter and a local are coloured apart, and CompletionResolver.cs answers what may be written where the caret sits - including what follows a dot, which needs the type of what precedes it and therefore needs a compiler. Both bind without emitting; the browser's own grammar colours each keystroke in the meantime so nothing waits on the network.

Snippets are compiled against the GenHTTP module surface with its namespaces already imported, so no using directives are needed. A guard (Compilation/CodeGuard.cs) rejects code that reaches for the file system, processes, reflection, sockets or the internals of this application before it is ever compiled, and a lambda that does need files gets a directory of its own under /data/workspaces.

Compiled lambdas run in the server process. The guard raises the cost of misbehaving; it is not a sandbox, which is why the container runs unprivileged and the seccomp profile is left alone.

Configuration

Everything is read from the environment on startup, see LambdaOptions.

VariableDefaultMeaning
LAMBDA_PORT8080port of the root server
LAMBDA_ENGINEioxideioxide or kestrel (see below)
LAMBDA_RECEIVE_QUEUE_ENTRIES4096reads of 32 KB a connection may have waiting on io_uring
LAMBDA_DATA_DIRECTORY./datadatabase, code and workspaces
LAMBDA_WEB_ROOT./wwwrootthe built frontend
LAMBDA_DEVELOPMENTfalseverbose error pages and debug logging
LAMBDA_DEPLOYMENT_LIFETIME_HOURS720unused for this long and it goes offline
LAMBDA_RETENTION_HOURS2160unused for this long and it is removed
LAMBDA_MAINTENANCE_INTERVAL_HOURS0.25how often expired lambdas are looked for
LAMBDA_MAX_CODE_LENGTH1048576characters of C# a lambda may have, in all
LAMBDA_PREMIUM_MAX_CODE_LENGTH10485760the same for a premium lambda, never less
LAMBDA_MAX_ASSET_BYTES33554432what the shipped assets may come to
LAMBDA_PREMIUM_MAX_ASSET_BYTES134217728the same for a premium lambda, never less
LAMBDA_WORKSPACE_BYTES268435456the room a workspace may take in all
LAMBDA_PREMIUM_WORKSPACE_BYTES2147483648the same for a premium lambda
LAMBDA_MAX_VERSIONS50versions kept per lambda
LAMBDA_RATE_LIMIT5000lambda requests per second and client
LAMBDA_MAX_CONCURRENCY64lambda requests executed at once
LAMBDA_EXECUTION_TIMEOUT_SECONDS15before an invocation is aborted
LAMBDA_TELEMETRY_INTERVAL_SECONDS30how often a reading is taken
LAMBDA_TELEMETRY_SAMPLES2880how many readings are kept
LAMBDA_PUBLIC_ACTIVITYtrueserve the per lambda activity to anyone
LAMBDA_ADMIN_TOKEN-enables the panel, and closes the figures
LAMBDA_LOG_HISTORY4000log lines the panel can read back, to 10^6
LAMBDA_LOG_MEMORY_MBderivedwhat their text may cost; whichever runs out first
LAMBDA_LOG_LAMBDA_OUTPUTtruekeep what lambdas print, filed under them
LAMBDA_LOG_REPEAT_WINDOW_SECONDS10fold identical lines; zero writes every one
LAMBDA_LOG_CLIENT_ADDRESStruerecord the address a request came from
LAMBDA_LOG_GEOtruesay which country a range is registered in
LAMBDA_LOG_GEO_REFRESH_HOURS168how often the registry files are refetched
LAMBDA_LOG_GEO_PLACEStruealso fetch the town and network databases
LAMBDA_LOG_MAX_LINES_PER_REQUEST200before one request's output is cut off
LAMBDA_MCP_ORIGINS-hosts a browser may use /mcp from
LAMBDA_PUBLIC_URL-canonical address, enables the sitemap
LAMBDA_TLS_PORT0port for TLS, zero leaves it off
LAMBDA_CERTIFICATE-PEM chain or PKCS#12 archive
LAMBDA_CERTIFICATE_KEY-private key, for a PEM pair
LAMBDA_CERTIFICATE_PASSWORD-password of the PKCS#12 archive
LAMBDA_CERTIFICATE_DIRECTORY-further certificates, one folder per name
LAMBDA_ACME_DIRECTORY-web root an ACME client writes challenges to

The io_uring engine is the default and the faster one, but container runtimes block the syscall in their default seccomp profile - so the image defaults to LAMBDA_ENGINE=kestrel. Set it back to ioxide where io_uring is available and allowed.

docker compose -f docker-compose.yml -f docker-compose.ioxide.yml up -d

That override runs the server on io_uring under seccomp-ioxide.json, which is the default profile of the runtime with io_uring_setup, io_uring_enter and io_uring_register added and nothing else. Without it the engine stops at io_uring_setup failed: -1 before the first request. The hole is small but it is real: io_uring reaches further into the kernel than ordinary sockets, and this platform runs code written by strangers in the same process.

The engine does not slow a sender down while a request is read more slowly than it arrives. It keeps what came in, and once LAMBDA_RECEIVE_QUEUE_ENTRIES reads are waiting it drops the connection with recv queue overflow on stderr. Its own default of 64 is two megabytes, which a JSON body of a few megabytes outruns while it is being parsed - an upload of 24 MB was dropped even at 5 MB/s. The reads come out of the 4096 each reactor shares, and when those run out it waits for them rather than dropping anybody - so the default here is 4096, at which a connection is only ever slowed down: 200 MB went through at the full speed of the loopback, where 1024 dropped 100 MB sent at 50 MB/s. The price is that one large upload can hold the reads of its reactor while it is consumed, and the other connections on that reactor wait for them. Kestrel slows the sender down and needs none of this.

A workspace file sent as it is, to …/files/:path/content, is written to the disk as it arrives: 200 MB went through with the server growing by 160 MB, where base64 in JSON costs several times the file. A connection dropped in the middle of a body looks like a body that ended, so such an upload is checked against its Content-Length and not kept unless all of it arrived - with 1024 reads, the partial file had been kept as if it were the whole.

Redeploying

An installation that also runs the build agent is spread over three compose files, and leaving one out does not fail loudly - it produces a server that looks healthy while some part of it is switched off. Drop the agent overlay and /build reports "no build agent"; drop the ioxide overlay and the engine quietly falls back to Kestrel. So there is a script that knows the list and checks the result:

sudo ./deploy.sh              # rebuild and restart what is on disk
sudo ./deploy.sh --pull       # fetch origin/main first, then do that
sudo ./deploy.sh --check      # verify the running server, change nothing

--check is safe at any time and is the quickest way to answer "is the build agent actually up?". A deploy with nothing to change recreates nothing, so it costs no downtime; when there is something to change it restarts the container, which drops every open connection - including anyone using a hosted lambda at that moment.

Linking it onto the path makes it available to anyone with sudo:

sudo ln -sf "$PWD/deploy.sh" /usr/local/bin/genhttp-deploy

Telemetry

/stats shows what the process is doing, and /api/v1/telemetry is where it comes from. A reading is taken every LAMBDA_TELEMETRY_INTERVAL_SECONDS and LAMBDA_TELEMETRY_SAMPLES of them are kept, which is a day at the defaults.

The readings live in memory and go with the process. That suits what they are for - a restart ends the run they were measuring - but it does mean a redeploy starts the graph over.

It also lists what each lambda has served, busiest first. Only the public key identifies one there - the editor key, the code and the visitors are not part of it - and LAMBDA_PUBLIC_ACTIVITY=false stops that list being served without stopping anything being counted.

The page is otherwise aggregates only: no key, no code and no client address leaves through it, which is what makes it safe to serve to anyone. What it is for is the shape of the memory curve. A managed heap that climbs across gen 2 collections is a leak; committed bytes climbing while the managed heap stays flat is the heap keeping pages it could return, which is not.

TLS

The server terminates TLS itself, so nothing has to sit in front of it. Point LAMBDA_CERTIFICATE at a certificate and set LAMBDA_TLS_PORT, and the plain port starts answering with a redirect to the secure one.

LAMBDA_TLS_PORT=8443
LAMBDA_CERTIFICATE=/certs/fullchain.pem
LAMBDA_CERTIFICATE_KEY=/certs/privkey.pem

A PKCS#12 archive works just as well - leave the key empty and set LAMBDA_CERTIFICATE_PASSWORD instead. The certificate is read again when the files change, so a renewal is picked up without a restart.

Shipping assets

A lambda is not only C#. Any file whose name does not end in .cs is an asset: it is served as it is, never compiled, and costs none of the code budget.

lambda.cs      the snippet
Types.cs       compiled beside it
index.html     an asset
www/app.css    an asset, in a folder
logo.png       an asset, sent as base64

The snippet reaches them through Assets, which is the other half of Workspace - what the lambda shipped, against what it has written since:

return Layout.Create()
             .Add("api", api)
             .Add(Assets.App());

Assets.App() is a single page application over them: index.html is the shell and a path matching no file is answered with it. Assets.Tree() and Assets.Files() are there for anything less opinionated, and content types come from the extension.

The directory is rewritten from the version being deployed, so an asset dropped from a version stops being served rather than lingering. LAMBDA_MAX_ASSET_BYTES is what they may come to in total, and LAMBDA_PREMIUM_MAX_ASSET_BYTES the same for a lambda in the premium tier. How many there are is not limited, and neither is the number of C# files - only what the code comes to, by LAMBDA_MAX_CODE_LENGTH and LAMBDA_PREMIUM_MAX_CODE_LENGTH.

Budget the disk for the premium tier. Assets are kept inside every version, as base64 in JSON, so each version saved at the limit takes nearly half as much again on disk - 184 MB for 128 MB of assets - and LAMBDA_MAX_VERSIONS of them are kept: at the defaults, a premium lambda that is saved over and over with all 128 MB of its assets can come to around 9 GB of history.

Budget the memory too. A version is read and written whole: with the 128 MB of assets the premium tier allows, saving one took the server to 2.3 GB and deploying it to 2.8 GB. Even the 32 MB of a free lambda took a server idling at 350 MB to 950 MB to save, and anybody can create a free lambda. Set LAMBDA_MAX_ASSET_BYTES and LAMBDA_PREMIUM_MAX_ASSET_BYTES to what the machine can carry until assets are stored apart from the versions.

More than one hostname

A certificate is only good for the names it carries, so a server answering to several needs one per name. LAMBDA_CERTIFICATE_DIRECTORY points at a folder holding the rest, a subdirectory per name in the layout an ACME client already keeps them in:

/certs/fullchain.pem              the default, for anything unrecognised
/certs/privkey.pem
/certs/example.com/fullchain.pem  presented to clients asking for example.com
/certs/example.com/privkey.pem

The names come from the certificates themselves rather than the folder names, including wildcards, and a client asking for a name none of them covers is answered with the default one.

Note that this needs a PEM pair per name when running on the io_uring engine. Kestrel terminates TLS in .NET and is handed a loaded certificate, but the io_uring engine reads the files itself and has no way to be given an archive.

The issuers in the file are published into the certificate store of the user the server runs as, because a client needs the chain and not just the leaf to reach a root. Kestrel papers over a missing chain by fetching the issuer over the network mid-handshake; the io_uring engine sends what it was given, so a server that never published its issuers answers it with a certificate nobody can verify.

There is no ACME client built in. With certbot, the private key stays readable by root only while the server runs unprivileged, so a deploy hook publishes the renewed files where the container can read them:

certbot certonly --standalone -d your.host.name

install -m 0644 -o root -g 1001 /etc/letsencrypt/live/your.host.name/fullchain.pem /opt/genhttp-lambda/certs/
install -m 0640 -o root -g 1001 /etc/letsencrypt/live/your.host.name/privkey.pem   /opt/genhttp-lambda/certs/

Because the server holds port 80, the standalone authenticator needs it back for the few seconds a renewal takes - a pre hook stops the container and a post hook starts it again.

Issuing certificates while it runs

The server can answer the challenges itself instead. Point LAMBDA_ACME_DIRECTORY at the folder the ACME client writes them into, and GET /.well-known/acme-challenge/{token} is served from {folder}/.well-known/acme-challenge/{token} - for every host the server receives, a lambda's own domain included, where the path would otherwise be the lambda's. Nothing else in the folder is served.

mkdir -p /opt/genhttp-lambda/acme     # mounted read only at /acme, see docker-compose.yml

certbot certonly --webroot -w /opt/genhttp-lambda/acme -d your.host.name

The challenge is asked for over plain HTTP, and it is the one request the upgrade to HTTPS lets through rather than redirecting.

Custom domains

A lambda in the premium tier can answer at a domain of its own, which its owner sets in the editor after pointing the domain's A and AAAA records at the server. Plain requests to it are redirected to HTTPS on the same domain like every other request, so it needs a certificate - issued by hand for now, with the web root above, into a folder of its own:

certbot certonly --webroot -w /opt/genhttp-lambda/acme -d shop.example.com

mkdir -p /opt/genhttp-lambda/certs/shop.example.com
install -m 0644 -o root -g 1001 /etc/letsencrypt/live/shop.example.com/fullchain.pem /opt/genhttp-lambda/certs/shop.example.com/
install -m 0640 -o root -g 1001 /etc/letsencrypt/live/shop.example.com/privkey.pem   /opt/genhttp-lambda/certs/shop.example.com/

The folder is only looked through on startup - the io_uring engine learns the names it holds certificates for when it opens the TLS port - so a new certificate is served after the next restart of the container. Renewals of a certificate the server knows are picked up without one. Until the certificate is there, visitors of the domain are redirected to HTTPS and shown the default certificate, which does not carry their name.

For agents

There is a Model Context Protocol endpoint at /mcp, so an agent can build something here and put it online without a person driving the editor. It is JSON-RPC over a single path, taking POST; a GET is declined with 405, because this server answers rather than streams.

https://genhttp.dev/mcp

The tools are the shape of the job: create_lambda, write_code, check_code, deploy, read_lambda, read_logs, and list_demos for reading something that already works. platform_guide is the one to call first - it says what a snippet has to return, what is imported, what is refused, and the handful of things that catch people out.

write_code takes an optional specification (what the user wants and why) and change (one line on what the version does). change_code changes only the files it names, or a passage within one, and either takes deploy: true or check: true - the latter compiles what it saved and answers with the diagnostics, without sending every file again the way check_code needs. A deployment that is refused names the version it refused and the one that is stillOnline. They are kept with the version and shown next to its diff in the control center, and read_lambda hands the recent history back so the next agent can read why before it changes anything. read_logs lets an agent see how what it deployed is answering, stack traces included.

read_lambda sends every file of a version while together they come to 30,000 characters, and names them with their lengths beyond that; file then fetches one in full, up to a megabyte, and anything larger is left to the zip of the version - a hundred megabytes of base64 is nothing an agent can read. It also says which tier the lambda is in and what that allows, and platform_guide lays out both tiers. The guide, write_code and upload_file also steer a large file that is data rather than program - a model, a dataset, media - into the workspace, where it is kept once instead of in every version, and a refusal for assets over the limit says the same.

Demos

The installation keeps a handful of finished lambdas online in the Demo tier, each showing one way to build something: demo-crud (a REST API over records in a JSON file), demo-registration (accounts, login and a members page), demo-game (a websocket game), demo-files (uploads) and demo-live (server-sent events). Their editor key is their public key, and it is meant to be announced: read_lambda, list_files and read_logs work on a demo exactly as on an agent's own lambda, and the editor at /editor/demo-crud shows it.

Everything that would change a demo - saving, deploying, stopping, moving, deleting, the workspace, the showcase - is refused by its tier, whichever way the request comes in. create_lambda with a demo's id as its template starts a lambda of one's own from a copy. Keys starting with demo- cannot be claimed.

The demos are seeded in the background after startup from the hidden templates of the same name in Resources/Templates, redeployed when their template changes, and retired when they leave DemoCatalog. Nobody can move a lambda into the tier or out of it, the operator included.

Nothing is created until acceptTerms is true, and the editor key that comes back is the only way into what was made. There is no session and nothing is remembered between calls: everything a call needs is in its arguments.

LAMBDA_MCP_ORIGINS lists the hosts a browser may drive the endpoint from. An agent speaking HTTP sends no Origin and is never checked against it; the list is there because a browser does, and a page on another site could otherwise aim somebody's browser at this endpoint.

Administration

/admin lists every lambda, when it was created, whether it is online, and what its code says - and takes them offline or removes them. /stats is what the process is holding and what the engine is carrying. Both are reached through the Admin menu in the header, which is where the token is entered: it is kept in session storage, so closing the tab locks it again.

This is the one part that authenticates. Everywhere else the editor link is the credential and it only ever reaches one lambda; this reads code that belongs to other people and can take their work away, so it asks for LAMBDA_ADMIN_TOKEN in an X-Admin-Token header. Until that variable is set there is no panel at all, and a wrong token is answered exactly like a missing one - an installation that has no panel and one that is guarding it look the same from outside.

The server figures follow the same token, on the rule that an installation with an administrator keeps them to them. Where no token is configured there is nothing to check a request against, so requiring one would only mean nobody could ever read them - there they stay public, as they were before there was a panel to put them behind, and LAMBDA_PUBLIC_ACTIVITY still decides the per lambda figures.

/logs is the tail of this run, live. It holds everything the server logged and everything a lambda printed while it was serving a request - the two are told apart because the console is shared but the attribution is not: the concern that serves a lambda marks the request, the mark travels with it through every await into the code of the user, and the writer over the console reads it back to decide whose line it is. Constructing a lambda is marked the same way, so a print at the top of a snippet is filed under it as well.

Identical lines are folded before they are ever written: the same request from the same caller answered the same way is counted rather than repeated, and one line every LAMBDA_LOG_REPEAT_WINDOW_SECONDS says how many there were. What the window bounds is how stale a count may be, not how many are folded into it - and a line already read never changes underneath the reader, which is what lets it work with a cursor that only moves forwards. A run that stops has its last count written when somebody next reads, because otherwise the tail of a burst would sit counted and unseen.

Fold repeats in the panel does the same thing to what is already loaded, turning the list into one row per distinct line with a count on it, which is what makes a log worth reading when something is repeating: a scanner knocking on five paths every two seconds is five rows rather than fifteen hundred. Each survivor sits where it last happened rather than where it started, because a log is read from the bottom. The same line from two different callers stays two rows - which of them is doing it is usually the question.

Callers lists everyone the log still holds something about - address, where from, how many requests, how many failed, first and last seen - over the whole ring rather than over the page on screen, because who is out there is a question about the run and not about the last screenful.

The effect is that one lambda can be read on its own, which is what the Log button beside each row in /admin does, and so can one caller - clicking an address narrows to it. The find box takes bare words that must all appear, !word for one that must not, and "a phrase" to keep it together; it matches the text, the source, the lambda and the caller, so !Requests leaves only what the server and the lambdas said. A warning the server logs about a lambda - the one the error handler writes when it throws - is filed under that lambda too, with the stack trace that was kept from the visitor.

Every line says which protocol it arrived over, which country the address is registered in, and which address it was being written for. That is what turns a path being hit five hundred times a minute from a mystery into a question, and it applies to more than the request line: a lambda's print and an error the server logged both carry the caller, because the request is marked once at the outside and everything below reads the mark back. A request that arrived through a proxy is recorded as the address it claimed and the hop it came from, since the forwarded header is written by whoever sent it - taking it at face value would let anyone write any address into this log. Addresses and user agents are pooled, so a million lines from a few dozen callers is a few dozen strings rather than a million. It is personal data, it is only ever served behind the token, and LAMBDA_LOG_CLIENT_ADDRESS=false leaves every line in place with nothing personal on it.

The country comes from the delegation files the five regional registries publish - the same records that say who was given which block - fetched weekly and cached on the data volume, so a server that restarts while they are unreachable still knows what it knew last week and one that has never reached them simply shows no countries. Nothing is asked of a third party, because doing this by lookup would mean handing somebody else the address of every visitor to the installation.

On top of that country, LAMBDA_LOG_GEO_PLACES adds a town and the name of the network an address is on - "Aveiro, PT · MEO" rather than "PT" - from the free DB-IP databases. They are about 130 MB on the data volume, refetched monthly, and cost nothing in memory: the format is a search trie and the files are memory mapped, so what they use is page cache the kernel can drop rather than heap that has to be paid for. Lookups are remembered by address, because a scanner is one address and five hundred requests: measured, a fresh address costs about thirty-five microseconds and six hundred bytes, one already seen costs seven hundredths of a microsecond and nothing. So the price is paid per caller rather than per request - a thousand requests from fifty callers is two microseconds each, and from a thousand callers is thirty-five.

Read it as the registration of a range and not the location of a person. Two results from this machine make the point: its IPv4 address is registered in Austria and its IPv6 address in Germany, and 1.1.1.1 - a resolver that answers from everywhere at once - reads as Australia, because that is where the block is registered. It tells a Portuguese visitor from a Singaporean scanner. It does not put anybody on a map, and LAMBDA_LOG_GEO=false stops it downloading anything at all.

The town is a guess rather than a record - measured and inferred by a third party, right about most consumer connections and wrong about most infrastructure - so it is shown as one and never as a position. Country and town data by DB-IP under CC BY 4.0, which the panel credits; the registry data is published by the RIRs themselves.

Three bounds keep it honest. The ring holds LAMBDA_LOG_HISTORY lines, up to a million, with a cap on the length of each; LAMBDA_LOG_MEMORY_MB caps what their text may cost, and whichever runs out first decides, so a lambda in a print loop costs a fixed amount of memory rather than a growing one and long lines simply mean fewer of them; and one request may only contribute LAMBDA_LOG_MAX_LINES_PER_REQUEST before the rest is counted and dropped, so it evicts its own output rather than everybody else's. A million ordinary request lines measured at about 220 MB, which is the number to budget against. Nothing here survives a restart. The container's stdout still has the whole run and is what to read when the question is about something that happened before the process started - reading the log through the panel is a convenience, not the record.

The panel also says how the run before this one ended, where it did not end cleanly. The server cannot answer "did it just crash" about itself - whatever it would have said went down with it - so each run leaves a note on the data volume, refreshed on the telemetry tick, and the next one reads it. A note with no stop stamped on it is a process that went away between one heartbeat and the next, and the memory, socket count and request count it was carrying at that beat are usually most of the answer. Being asked to stop is stamped as the signal arrives rather than once the stopping is done, so a run that was told to go and died partway can be told from one that nothing asked at all. An unhandled exception is written into the note before the process goes down, because in a container that restarts immediately, stderr is easy to lose.

LAMBDA_LOG_LAMBDA_OUTPUT=false turns off the gathering of what lambdas print, for an installation that would rather not hold a stranger's output in memory at all. Their console still reaches stdout; it is simply not collected. The route is behind the token without the exception the figures get: aggregates name nobody, and this is whatever somebody's code decided to print.

The listing links the editor of each lambda, which is the whole of the editor key and therefore the whole of the credential. That is a deliberate trade: an administrator who has to decide whether something is abusive needs to read it, and reading it in the editor is also how it gets emptied or corrected rather than only deleted. It is one more reason the token belongs to a person and not in a browser somebody else uses.

Database

SQLite through EF Core, migrated on startup by Evolve from the SQL files in Data/Migrations. To change the schema, add the next V<n>__<name>.sql next to them - they are embedded resources, and the existing ones are never edited.

Setup from the maintainer

This listing does not have a supported local package template. Use the maintainer’s documentation for its hosted endpoint, authentication, and client-specific setup. No install command has been inferred.

Learn More