●CHROME — Gemini in Chrome lands on Android in late June with Nano Banana and auto browse, rolling out first to 4GB+ RAM devices set to en-US●OMNI-FLASH — Gemini Omni Flash rolls out to all AI Plus, Pro, and Ultra subscribers, and is free for adults in YouTube Shorts Remix and YouTube Create●DEADLINE — 12 days until the image preview models shut down on Jun 25 — migrate gemini-3.1-flash and 3-pro image-preview workloads to GA versions now●SCHEMA — The legacy Interactions API schema was removed on Jun 8; double-check your migration to the steps array and the new response_format●FLASH-GA — Gemini 3.5 Flash is generally available via Antigravity, the Gemini API, AI Studio, and Android Studio●SUITE — Deep Think, Deep Research, Gemini Live, and Gemini Omni now form one flow: reason, research, talk, and create●CHROME — Gemini in Chrome lands on Android in late June with Nano Banana and auto browse, rolling out first to 4GB+ RAM devices set to en-US●OMNI-FLASH — Gemini Omni Flash rolls out to all AI Plus, Pro, and Ultra subscribers, and is free for adults in YouTube Shorts Remix and YouTube Create●DEADLINE — 12 days until the image preview models shut down on Jun 25 — migrate gemini-3.1-flash and 3-pro image-preview workloads to GA versions now●SCHEMA — The legacy Interactions API schema was removed on Jun 8; double-check your migration to the steps array and the new response_format●FLASH-GA — Gemini 3.5 Flash is generally available via Antigravity, the Gemini API, AI Studio, and Android Studio●SUITE — Deep Think, Deep Research, Gemini Live, and Gemini Omni now form one flow: reason, research, talk, and create
Reading a Night of Logs in Three Minutes — Building My Own Daily Brief for Ops With the Gemini API
Inspired by Gemini's Daily Brief, I built a pipeline that turns overnight operations logs into one morning email: collect, summarize with response_schema, render, deliver — with measured token counts and a fallback that kept working through the June outage.
One morning in June 2026, I realized that checking the logs from my overnight automation was eating close to thirty minutes before I had touched any real work. Publishing logs for my blog network, crash digests for my wallpaper apps, daily search-performance snapshots, backup reports. As an indie developer running several pipelines at night, my first task of the day had quietly become log patrol.
That same week, Google announced Daily Brief for Gemini — an agent that analyzes your inbox, calendar, and tasks overnight and hands you a personal digest in the morning. Reading the announcement, my immediate thought was that I wanted this exact idea for my own operations logs. Daily Brief covers data inside Google services; the logs piling up on my own servers are out of its reach. So I built a small version of the same concept on the Gemini API.
The result: one email at 7:00 every morning, read in about three minutes, with deep dives reserved for the days that actually need them. This is the implementation record.
Why Morning Log Patrol Falls Apart
It helps to be honest about how my old routine was failing.
Four systems run overnight: automated article publishing for my tech blogs, draft replies for app store reviews, a daily search-data fetch, and backups. Each writes logs in a different place, in a different format. On a good night, none of it is worth reading — yet I had to open every location anyway, because a silent failure is exactly the thing you cannot afford to miss. That asymmetry is the problem.
Less than a tenth of the logs deserve attention. Failures need detail; successes need one line saying so
Formats never match. Cron stdout, JSON reports, and CSV snapshots force constant mental gear-switching
A skipped patrol becomes an incident. I once skipped a day and discovered, a day late, that a task had quietly exhausted its retry budget and stopped
What I needed was a layer that reads across every source, surfaces only the anomalies, and compresses everything healthy into a single sentence. That is precisely the kind of work a language model is good at.
The Overall Design — Collect, Summarize, Render, Deliver
The pipeline has four stages, each with exactly one responsibility.
Collect: gather the last 24 hours of logs from every source into one JSON payload. No LLM involved
Summarize: send the payload to Gemini 3.5 Flash and receive a structured digest via response_schema
Render: convert the structured data into an email body. No LLM involved
Deliver: send the email. This stage must run even when summarization fails
Only the second stage touches the LLM. Keeping the model dependency confined to a single stage turned out to matter a great deal on the morning of the outage, as you'll see below.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you spend 20-30 minutes every morning walking through scattered overnight logs, you can now replace that with one digest email you read in three minutes
✦You'll learn how to use response_schema so the summary arrives as decision-ready structured data instead of prose that drifts from day to day
✦You can reuse a fallback design, tested during the June 2026 outage (error 1076/1099), that keeps the morning delivery alive even when summarization fails
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Step 1: Shaping Logs Into Something Gemini Can Use
The collector needs nothing beyond the standard library. It reads each source and packs the text into a dictionary keyed by source name.
# collect_logs.py — gather the last 24 hours of overnight logs into one JSON payloadimport jsonfrom datetime import datetime, timedeltafrom pathlib import PathLOG_SOURCES = { "article_publish": Path("logs/publish"), # automated article publishing "app_reviews": Path("logs/reviews"), # review-reply draft generation "seo_snapshot": Path("logs/search"), # daily search-data fetch "backup": Path("logs/backup"), # backup reports}MAX_CHARS_PER_SOURCE = 8000 # per-source cap; older lines are dropped firstdef collect_yesterday() -> dict: since = datetime.now() - timedelta(hours=24) payload = {} for name, root in LOG_SOURCES.items(): if not root.exists(): payload[name] = "(no logs)" continue chunks = [] for f in sorted(root.glob("*.log")): if datetime.fromtimestamp(f.stat().st_mtime) < since: continue chunks.append(f.read_text(encoding="utf-8", errors="replace")) text = "\n".join(chunks).strip() # When over the cap, keep the TAIL — final outcomes live at the end of a log payload[name] = text[-MAX_CHARS_PER_SOURCE:] if text else "(no logs)" return payloadif __name__ == "__main__": data = collect_yesterday() print(json.dumps({k: len(v) for k, v in data.items()})) # Expected output, e.g.: {"article_publish": 4213, "app_reviews": 1877, "seo_snapshot": 902, "backup": 311}
Two unglamorous details matter here. First, when a source exceeds the cap, keep the tail, not the head. Overnight logs record their verdict — success or failure — at the end. My first version used text[:MAX_CHARS_PER_SOURCE] and cheerfully cropped the failures out of the digest.
Second, mask personal data at this stage. In my case, review-reply logs still contained user nicknames. Print the payload once and read it with your own eyes before anything leaves for an external API.
Step 2: Receiving Decision-Ready Data With response_schema
The heart of the summarization stage is structured output. My first prototype simply asked the model to "summarize these logs," and the tone, ordering, and structure drifted from one day to the next — which meant the interpretation time I was trying to eliminate crept right back in. Once I pinned down a response_schema, every section of the email landed in the same place every morning, and my reading speed became predictable.
# summarize.py — have Gemini 3.5 Flash produce a structured digestimport jsonfrom google import genaifrom google.genai import typesclient = genai.Client() # reads the GEMINI_API_KEY environment variableDIGEST_SCHEMA = types.Schema( type=types.Type.OBJECT, properties={ "headline": types.Schema(type=types.Type.STRING), "incidents": types.Schema( type=types.Type.ARRAY, items=types.Schema( type=types.Type.OBJECT, properties={ "source": types.Schema(type=types.Type.STRING), "severity": types.Schema( type=types.Type.STRING, enum=["info", "warn", "critical"], ), "summary": types.Schema(type=types.Type.STRING), "action": types.Schema(type=types.Type.STRING), }, required=["source", "severity", "summary"], ), ), }, required=["headline", "incidents"],)SYSTEM_INSTRUCTION = ( "You are an operations assistant for a solo developer. " "Read the overnight logs and extract only what matters for a morning decision. " "Do not list healthy runs as incidents; fold them into a one-line headline. " "Errors, retries, and unexpected output go into incidents, each with a " "severity and the action the developer should take this morning.")def summarize(payload: dict) -> dict: res = client.models.generate_content( model="gemini-3.5-flash", contents=json.dumps(payload, ensure_ascii=False), config=types.GenerateContentConfig( system_instruction=SYSTEM_INSTRUCTION, response_mime_type="application/json", response_schema=DIGEST_SCHEMA, temperature=0.2, ), ) return json.loads(res.text)
Two design decisions are worth spelling out.
Never let the model decide that everything is fine. An earlier version included an all_clear boolean in the schema and let the model set it. Some mornings it reported warn-level events while still flagging all clear. Now my code checks whether the incidents array is empty and makes that call itself. The model extracts; the machine decides. False reassurance stopped the day I drew that line.
Constrain severity to three enum values. Free-form severity produces variations like "moderate" or "somewhat serious," and the rendering stage can no longer branch on them. Schema enums exist for exactly this situation.
After roughly thirty days in operation, here are my measurements. Treat them as orders of magnitude; your log volume will differ.
Input tokens: about 9,000-13,000 per run (four sources, roughly 20-28 KB of raw text)
Output tokens: 300-500 on a quiet morning; around 900 on a busy one
Latency: 4-6 seconds for the generate_content call; under 10 seconds for the whole pipeline
Cost: on Flash-tier pay-as-you-go pricing, thirty days of this never added up to the price of a cup of coffee on my billing dashboard
Input stays near ten thousand tokens only because Step 1 trims aggressively with MAX_CHARS_PER_SOURCE. Feed raw logs untrimmed and you'll burn tens of thousands of tokens on boilerplate success lines. Mechanical reduction before the model isn't just a cost measure — in my experience, less noise measurably improves incident extraction.
Step 4: Cron and Delivery — Landing at 7:00
Rendering and delivery run on smtplib from the standard library. I never felt the need for HTML email; fixed-width plain text reads as a natural continuation of the terminal logs it summarizes.
# deliver.py — render the digest as a plain-text email and send itimport smtplibfrom email.mime.text import MIMETextdef render(digest: dict) -> str: lines = [f"# {digest['headline']}", ""] if not digest["incidents"]: lines.append("No incidents. Every pipeline completed normally.") for it in digest["incidents"]: mark = {"info": "-", "warn": "[warn]", "critical": "[CRIT]"}.get(it["severity"], "-") lines.append(f"{mark} {it['source']}: {it['summary']}") if it.get("action"): lines.append(f" -> {it['action']}") return "\n".join(lines)def send(body: str) -> None: msg = MIMEText(body, "plain", "utf-8") msg["Subject"] = "Morning Ops Digest" msg["From"] = "ops@example.com" msg["To"] = "me@example.com" with smtplib.SMTP("localhost") as s: s.send_message(msg)
Cron fires at 6:40, working backward from the delivery target. The pipeline finishes in ten seconds, but the retry logic below can hold things up for a few minutes, and I wanted that slack built in.
# crontab — run at 6:40 every morning (delivery completes before 7:00 even with retries)40 6 * * * cd /home/me/ops-digest && /usr/bin/python3 run_digest.py >> cron.log 2>&1
The Outage Morning — Summarization May Stop, Delivery May Not
In mid-June 2026, Gemini went through a major outage, with error 1076 and error 1099 reported widely. My digest failed at the summarize stage that morning — and the fallback did its job, delivering a degraded digest right on schedule. The part of the design I had spent the most time on worked exactly as intended, on the one morning it had to.
# run_digest.py — entry point that never lets a summarization failure stop deliveryimport timefrom collect_logs import collect_yesterdayfrom summarize import summarizefrom deliver import render, sendRETRIES = 3def degraded_digest(payload: dict) -> str: # The "raw-log condensed" edition, for mornings when Gemini is unavailable lines = ["# [DEGRADED] Summarization API unavailable; showing the tail of each source", ""] for name, text in payload.items(): tail = text.strip().splitlines()[-5:] lines.append(f"--- {name} ---") lines.extend(tail) lines.append("") return "\n".join(lines)def main() -> None: payload = collect_yesterday() for attempt in range(1, RETRIES + 1): try: send(render(summarize(payload))) return except Exception: if attempt == RETRIES: send(degraded_digest(payload)) return time.sleep(30 * attempt) # back off: 30s, then 60sif __name__ == "__main__": main()
The reasoning is a matter of priorities: the real value of this system is not the summary — it is that something arrives every single morning. When summarization is down, I get the last five lines of each source instead. It takes ten minutes to read rather than three, but that beats "nothing arrived, back to manual patrol" by a wide margin.
Three holes I fell into, preserved here so you can step around them.
Truncating from the wrong end. As above: text[-N:], not text[:N]. Overnight logs write their conclusions last. I lost three days of failures to this before noticing.
Overstuffing the schema. The prototype schema included "probable cause" and "related commit" fields. The model filled them with thinly grounded guesses, and the digest's credibility suffered. A digest's job ends at "here is where to look this morning"; root-cause analysis belongs to a human reading the linked logs. Removing fields made the output more useful, not less.
Leaving the digest itself unmonitored. If nothing tells you the delivery stopped, the digest becomes your newest single point of failure. I pipe the cron exit code into separate monitoring and rely on the simple habit of noticing a missing weekday email. As I found when I moved Batch API polling to webhooks (Retiring the Midnight Polling Loop — Rebuilding My Gemini Batch Monitoring Around Webhooks), a thin layer of meta-monitoring buys a lot of calm.
Where to Start — One Source Is Enough
There is no need to build all four stages at once. Take the log of a single cron job, read it with something like collect_yesterday(), send it to Gemini 3.5 Flash with a response_schema, and watch structured JSON come back. Once that loop works, adding sources and delivery channels is routine.
After thirty days, the biggest change isn't the reclaimed minutes — it's the disappearance of that low hum of "what if I missed something." If you're carrying overnight automation of your own, I hope this record saves you a few mornings.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.