Eight years in production. Currently Technical Lead SRE at CloudBolt.
I make large systems tell the truth about themselves — then make them faster, cheaper, and quieter on-call.
Technical Lead SRE for a cloud FinOps platform — owner of observability, reliability and the AI/agentic tooling layer. Before that, performance engineering on a compliance archive taking in five petabytes a day. Performance engineering, DevOps, CI/CD, distributed systems.
Eight years as one trace. Bar length is time in role. Pick a span to read the work inside it.
Leading SRE for a cloud FinOps platform — owner of observability, reliability, and the AI/agentic tooling layer.
Mira was a new product whose landing page took seven seconds to load, and the brief was to at least halve it. There was no tracing anywhere in the stack — no spans, no instrumentation, nothing to ask a question of. So the first job wasn’t the latency, it was building something that could see it.
The network waterfall in devtools ruled out the front end immediately: the browser was waiting, not working. The time sat in the backend calls, GraphQL resolvers running on Lambda, and the same call varied wildly between requests — which is the signature of something other than the query being slow.
You cannot see inside a Lambda invocation from the outside, so I built the trace flow from zero — OpenTelemetry collectors through to Honeycomb — and instrumented the handler down to individual operations rather than stopping at the request boundary. That resolution is what made the next step possible.
tracer = configure_tracing("mira-actions")
dynamodb = None
def execute_query(key_expression=None, filter_expression=None, attribute_names=None,
attribute_values=None, sort_field=None, sort_direction=None):
global dynamodb
table_name = os.environ["MIRA_ACTIONS_TABLE"]
try:
with tracer.start_as_current_span("mira-action-read-execute-query") as parent:
parent.set_attribute("db.system", "dynamodb")
parent.set_attribute("db.table", table_name)
# the 2.66 s was hiding in here
with tracer.start_as_current_span("dynamodb-resource-initialization"):
if dynamodb is None:
dynamodb = boto3.resource("dynamodb")
with tracer.start_as_current_span("dynamodb-table-access") as span:
table = dynamodb.Table(table_name)
span.set_attribute("dynamodb.table_name", table_name)
kwargs = {}
with tracer.start_as_current_span("dynamodb-query-construction") as span:
if key_expression is not None:
kwargs["KeyConditionExpression"] = key_expression
if filter_expression is not None:
kwargs["FilterExpression"] = filter_expression
if attribute_names is not None:
kwargs["ExpressionAttributeNames"] = attribute_names
if attribute_values is not None:
kwargs["ExpressionAttributeValues"] = attribute_values
if sort_field is not None:
kwargs["IndexName"] = f"tenant_id-{sort_field}-index"
kwargs["ScanIndexForward"] = sort_direction == "asc"
span.set_attribute("dynamodb.query_arguments", str(kwargs))
with tracer.start_as_current_span("dynamodb-query-execution") as span:
result = table.query(**kwargs)
span.set_attribute("dynamodb.query_result_count", len(result.get("Items", [])))
return result["Items"]
finally:
flush_traces()
Two spans owned almost the whole request. DynamoDB resource initialisation burned 2.66 s before a single query was constructed, and query execution took a further 8.24 s. Query construction itself — the part you’d suspect — took 0.27 ms.
Our working assumption was cold starts, and the reflex fix for that is warming or provisioned concurrency. Both would have been wasted money. The cost was memory. boto3 assembles its entire API surface from JSON files on first use, and Lambda scales CPU with allocated memory — so an under-provisioned function spends seconds parsing service models before it has talked to anything. tecRacer measured this at more than 10× slower on 128 MB than on 1408 MB, which matched what our spans were showing. Adding concurrency would have made it worse: more containers, each paying the full initialisation.
Right-sized memory on the affected functions. Landing page load dropped from 7 s to roughly 500 ms; the actions endpoint went from 7.92 s to 1.29 s.
The instrumentation was the real deliverable. Without spans inside the handler, “the page is slow” and “probably cold starts” would both have stayed true, unfalsifiable, and useless — and the fix would have been more Lambda concurrency, which would have cost money and changed nothing.
Performance engineering owner for a compliance archiving platform at petabyte-per-day scale.
The Kafka clusters had been sized for a worst case that never arrived, and there was one in nearly every region we ran. Oversized Kafka is expensive in a quiet way — it never pages anyone, it just bills every hour of every month. The savings were obvious on a dashboard. Collecting them was not.
Scaling a stateless app down is a number in a manifest. A Kafka broker is not stateless — it holds partition replicas, and some of those replicas are the in-sync ones other brokers and producers are depending on right now. Terminate the instance and you are not removing capacity, you are removing data and quite possibly breaking every producer configured to require acknowledgement from the in-sync replica set.
Deployment was managed through BOSH, with the cluster topology described in manifest files. BOSH decides which instance goes when the count drops, based on deployment order rather than on which broker happens to be convenient — so the first thing to establish was not how many nodes to remove but which specific node was going to disappear, and then to target it deliberately.
Before the instance could go, its partitions had to move. That meant going into the cluster and running the reassignment from the Kafka console to shift replicas onto the remaining brokers, then waiting — and it is genuinely a wait, because the new replicas have to pull the existing log from scratch and catch all the way up before they join the in-sync set. Only once under-replicated partitions were back to zero was the broker actually carrying nothing, and only then could BOSH take the node.
All of this ran against live production clusters with real ingestion flowing through them. The sequencing is the whole job: drain, verify, remove, confirm the cluster is healthy, then start again on the next broker. Rush any step and the failure is silent data loss rather than a clean error.
Right-sized Kafka across nearly every region we operated, worth $100–150K a year, with no downtime and no lost messages at any point in the exercise.
I cut the BOSH release for Storm 2.3 and promoted the package to S3, so every Storm cluster in production would pull the new version and upgrade its base cluster on the next deployment. One artefact, one promotion, the whole estate follows.
Kryo registration exceptions in the ingestion topologies. Storm 2.2 still fell back to Java serialisation for tuples crossing a worker boundary; 2.3 deprecates that path in favour of Kryo, which refuses to serialise any class it has not been told about. Those topologies passed custom objects between bolts and had never needed to register them, because until 2.3 nothing forced the question.
The first topologies to go were the ones on local field grouping — Supervision, eDiscovery, the S3 marker path. Not everything failed, and the failures did not line up with anything obvious about the topologies themselves. The pattern only came out once we stopped sorting the exceptions by topology and started sorting them by how tuples were moving between workers.
Caught at the start of the rollout, so nothing was lost. I cut a fresh BOSH 2.2 release, promoted it the same way, and redeployed the clusters — the rollback was a cluster deployment and nothing more, because the upgrade path had been built to run in both directions. No ingestion failures, no compliance records dropped.
Registered the tuple classes with Kryo in the affected topologies, then re-promoted 2.3 and took the estate through the upgrade again. Second time it held.
A deprecation in the release notes can be invisible right up until a specific runtime condition makes it mandatory — and the condition here was a design choice made years earlier in a different file. The thing that made this safe was not spotting it in review; it was that rolling back cost one deployment.
Performance and platform engineering on Enterprise Archive, a cloud-based compliance archive for email, social, and real-time communication channels.
A JPMorgan Chase deployment needed sustained ingestion of 1,000 documents per second. The pipeline was doing 30–40. At an average message size of ~220 KB that target is roughly 220 MB a second of continuous ingest — and because this is a compliance archive, none of it may be dropped, reordered into uselessness, or left unsearchable.
Nothing could be tuned in isolation, because a document crosses eight systems between arriving as an email and becoming searchable. The first job was writing that path down honestly.
A small Go service that speaks SMTP, accepts the raw email and hands it off to RabbitMQ.
Consumes from RabbitMQ and converts the email document into XML.
Validates the XML with a SAX parser before anything downstream trusts it, then writes to Kafka. Malformed documents stop here rather than three systems later.
Anything under 1 MB goes straight onto the partition. Anything larger is written to S3 and streamed by metadata id instead, so a 30 MB attachment never has to fit inside a broker message.
Spouts pull from the ingestion consumer group into Storm’s in-memory queue, then documents move through the bolts. Three topologies by this point: email ingestion, non-email ingestion, and capture across 80+ channels.
The full document lands in S3. Two PCF microservices fire off the bolts — EHMS runs attachments through Tika to turn PDFs and Office files into searchable text, and the identity service reconciles senders, recipients and CC across sources.
An archive-metrics collection in MongoDB records where each document is: EMLR on receipt, DIG once validated, Q once queued, then ingested and finally indexed.
The indexing bolt hands searchable metadata to a service that writes it to Elasticsearch — which is what legal teams actually query when they search by recipient, CC or date.
Every stage had its own. RabbitMQ and Kafka were configured for a fraction of the target. Storm’s in-memory queue and topology parallelism throttled everything behind it. The PCF routers and the apps behind them needed scaling out. Hazelcast needed rework. Queries against the metadata stores needed indexes they had never needed at 40 docs/sec. Removing one ceiling only ever revealed the next.
Horizontal scaling where the work partitioned cleanly and vertical where it did not, PCF router and app scaling, a Storm topology rework, Kafka and RabbitMQ resizing, Hazelcast changes, database indexing, and a pass over the per-document code path to remove work that at 40 docs/sec was invisible and at 1,000 was structural. Each stage was benchmarked on its own before and after, because a pipeline this long will otherwise happily hide a regression behind an improvement.
The system reached 1,000 docs/sec and held it, throughput up roughly 25× while cost went down. It brought recognition across the org and my promotion to Delivery Engineer 3.
Developer and QA automation engineer across financial-domain clients, building REST service test frameworks from scratch — primarily Rest Assured in a non-BDD style.
The unglamorous part turned out to be the useful part: release docs, L2/L3 incidents and late-night calls are where I learned what production actually does under stress.
The numbers I'd want to be asked about. Each one is a before and an after on the same system.
Annualised cloud savings, each one traced back to a specific change in a specific system.
| Change | Per year |
|---|---|
| Automated Storm cluster sizing and targeted scale-downSmarsh · ~$3,800/day | $1.0M+ |
| T-shirt sizing the environment estate against real per-customer throughputSmarsh · micro / mini / major | $1.3M |
| Kafka cluster right-sizing across regionsSmarsh · live broker decommissioning | $150K |
| Datadog-driven analysis across 19 regionsSmarsh · remainder of the $2M+ programme | $550K+ |
| Stabilising a critical microserviceSmarsh · RDS | $300K |
| RDS PostgreSQL tuning and downscalingCloudBolt · indexing, autovacuum, WAL | $85K |
| MongoDB Intel → Graviton migrationCloudBolt · with right-sizing | $48K |
| Total identified | $3.5M+ |
An agentic loop from alert to pull request, with humans holding the last gate. Built at CloudBolt.
Errors, latency regressions and query spikes surface through Scout APM, RDS Performance Insights and pg_stat_statements.
An internally deployed MCP server fetches the logs and telemetry around the event, so the agent reasons over real evidence rather than a summary.
Correlates the alert, gathers root-cause data, and writes a Jira epic with reproduction steps and the exact remediation commands. Posts to Slack with an engineer assigned.
On transition to In Progress, a Claude-powered agent writes the fix and opens a pull request against the right repository.
The PR passes through LLM review built to argue against it, using a tree-sitter knowledge graph to keep token cost down on large diffs.
Nothing merges without a person. The agents remove the tedium; they don't hold the pen.
Model-agnostic with swappable backends — Codex was evaluated in a POC, Claude standardised on for better review quality at lower token usage — with AI cost tracked automatically.
Filled boxes are what I reach for first.
Happy to talk about reliability work, performance forensics, or where agents actually help on-call.