[{"data":1,"prerenderedAt":2132},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run":3,"surround:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run":2099,"ancestors:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run":2107,"children:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run":2111,"related:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run":2112},{"id":4,"title":5,"body":6,"dateModified":2066,"datePublished":2066,"description":2067,"extension":2068,"faq":2069,"keyword":2078,"kind":2079,"meta":2080,"navigation":267,"path":2088,"seo":2089,"slug":2092,"stem":2093,"topics":2094,"type":2097,"__hash__":2098},"docs\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run\u002Findex.md","Detect a Scheduled Report That Did Not Run",{"type":7,"value":8,"toc":2054},"minimark",[9,24,29,64,71,211,215,226,582,588,592,595,866,869,990,994,997,1215,1223,1227,1230,1407,1417,1508,1524,1631,1635,1638,1933,1937,1972,1976,1989,1993,1999,2005,2011,2017,2021,2050],[10,11,12,13,17,18,23],"p",{},"Most error handling assumes the job runs. Retries cover a flaky database, alerts cover an exception, logs cover everything in between — and none of it fires when the job never starts. The server was rebuilt and the crontab was not restored. The Kubernetes CronJob was suspended during an incident and never resumed. The laptop that ran the report was replaced. The Monday report simply does not appear, and the first alert is a colleague asking where it is. Detecting that needs a check that lives ",[14,15,16],"em",{},"outside"," the job: either something that looks at the job's output and compares it with the schedule, or an external monitor the job has to check in with. This page, part of ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","Error Handling and Logging in Excel Automation",", builds both.",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install croniter pandas openpyxl requests\n","bash","",[37,38,39],"code",{"__ignoreMap":35},[40,41,44,48,52,55,58,61],"span",{"class":42,"line":43},"line",1,[40,45,47],{"class":46},"sMTad","pip",[40,49,51],{"class":50},"srMev"," install",[40,53,54],{"class":50}," croniter",[40,56,57],{"class":50}," pandas",[40,59,60],{"class":50}," openpyxl",[40,62,63],{"class":50}," requests\n",[10,65,66,67,70],{},"The ",[37,68,69],{},"share\u002F"," folder holds the output of three scheduled reports: a weekly sales report due every Monday at 07:00, a daily stock report due at 06:30 on weekdays, and a month-end pack due at 08:00 on the 1st of each month. The checks run at 10:00 on Tuesday 29 September 2026. For the heartbeat examples, a tiny local HTTP server stands in for the external monitoring service, so the requests the job sends can be inspected.",[72,73,81,82,81,86,81,90,81,98,81,108,81,119,81,126,81,132,81,138,81,146,81,150,81,155,81,159,81,166,81,170,81,177,81,182,81,186,81,190,81,195,81,198,81,203,81,206],"svg",{"viewBox":74,"role":75,"ariaLabelledBy":76,"xmlns":79,"style":80},"4 0 752 207","img",[77,78],"dr-two-t","dr-two-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[83,84,85],"title",{"id":77},"Two ways to notice a missing run",[87,88,89],"desc",{"id":78},"Two panels. A freshness check looks at each report's output and compares its age with the schedule, and needs no change to the jobs. A heartbeat has each job ping an external monitor, which alerts when a ping is late, and catches runs that start but hang.",[91,92],"rect",{"x":93,"y":94,"width":95,"height":96,"fill":97},"4","0","752","207","#ffffff",[91,99],{"x":100,"y":101,"width":102,"height":103,"rx":104,"fill":105,"stroke":106,"style":107},"20.0","16.0","351.0","175.0","14","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[109,110,112],"g",{"transform":111},"translate(182.5,29.0) scale(1.083)",[113,114],"path",{"d":115,"fill":116,"stroke":117,"style":118},"M11 4a7 7 0 1 0 .01 0z M16 16l5 5","none","var(--brand-strong,#4338ca)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[120,121,125],"text",{"x":122,"y":123,"style":124},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Freshness check",[42,127],{"x1":128,"y1":129,"x2":130,"y2":129,"stroke":106,"style":131},"36.0","91.0","355.0","stroke-width:1px",[120,133,137],{"x":134,"y":135,"style":136},"60.0","111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:start","no change to the jobs",[109,139,141],{"transform":140},"translate(38.5,99.5) scale(0.625)",[113,142],{"d":143,"fill":116,"stroke":144,"style":145},"M5 12.5l4.5 4.5L19 7","var(--teal,#0f9488)","stroke-width:2.88px;stroke-linecap:round;stroke-linejoin:round",[120,147,149],{"x":134,"y":148,"style":136},"137.0","checks what readers actually get",[109,151,153],{"transform":152},"translate(38.5,125.5) scale(0.625)",[113,154],{"d":143,"fill":116,"stroke":144,"style":145},[120,156,158],{"x":134,"y":157,"style":136},"163.0","needs one monitoring job of its own",[109,160,162],{"transform":161},"translate(38.5,151.5) scale(0.625)",[113,163],{"d":164,"fill":116,"stroke":165,"style":145},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4","var(--gold,#b4740a)",[91,167],{"x":168,"y":101,"width":102,"height":103,"rx":104,"fill":169,"stroke":144,"style":107},"389.0","#d9f4f1",[109,171,173],{"transform":172},"translate(551.5,29.0) scale(1.083)",[113,174],{"d":175,"fill":116,"stroke":176,"style":118},"M3 11l18-8-8 18-2-8z","var(--teal-ink,#0b6157)",[120,178,181],{"x":179,"y":123,"style":180},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Heartbeat ping",[42,183],{"x1":184,"y1":129,"x2":185,"y2":129,"stroke":144,"style":131},"405.0","724.0",[120,187,189],{"x":188,"y":135,"style":136},"429.0","monitor lives outside your servers",[109,191,193],{"transform":192},"translate(407.5,99.5) scale(0.625)",[113,194],{"d":143,"fill":116,"stroke":144,"style":145},[120,196,197],{"x":188,"y":148,"style":136},"start and fail signals too",[109,199,201],{"transform":200},"translate(407.5,125.5) scale(0.625)",[113,202],{"d":143,"fill":116,"stroke":144,"style":145},[120,204,205],{"x":188,"y":157,"style":136},"each job must send the pings",[109,207,209],{"transform":208},"translate(407.5,151.5) scale(0.625)",[113,210],{"d":164,"fill":116,"stroke":165,"style":145},[25,212,214],{"id":213},"step-1-when-should-each-report-have-last-run","Step 1 — When should each report have last run?",[10,216,217,218,221,222,225],{},"The key calculation is the ",[14,219,220],{},"previous"," scheduled time: at 10:00 on Tuesday, the weekly report was last due at 07:00 on Monday. ",[37,223,224],{},"croniter"," computes it from the same cron expression the scheduler uses:",[30,227,231],{"className":228,"code":229,"language":230,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","from datetime import datetime, timedelta\nfrom croniter import croniter\n\nREPORTS = {\n    \"weekly_sales.xlsx\":   {\"cron\": \"0 7 * * 1\",   \"grace\": timedelta(hours=2)},\n    \"daily_stock.xlsx\":    {\"cron\": \"30 6 * * 1-5\", \"grace\": timedelta(hours=1)},\n    \"month_end_pack.xlsx\": {\"cron\": \"0 8 1 * *\",   \"grace\": timedelta(days=3)},\n}\ncheck_time = datetime(2026, 9, 29, 10, 0)\n\nfor name, spec in REPORTS.items():\n    due = croniter(spec[\"cron\"], check_time).get_prev(datetime)\n    print(f\"{name:20} last due {due:%a %d %b %H:%M}, overdue after {due + spec['grace']:%a %d %b %H:%M}\")\n","python",[37,232,233,249,262,269,282,322,354,386,392,428,433,451,467],{"__ignoreMap":35},[40,234,235,239,243,246],{"class":42,"line":43},[40,236,238],{"class":237},"s-kum","from",[40,240,242],{"class":241},"skGVy"," datetime ",[40,244,245],{"class":237},"import",[40,247,248],{"class":241}," datetime, timedelta\n",[40,250,252,254,257,259],{"class":42,"line":251},2,[40,253,238],{"class":237},[40,255,256],{"class":241}," croniter ",[40,258,245],{"class":237},[40,260,261],{"class":241}," croniter\n",[40,263,265],{"class":42,"line":264},3,[40,266,268],{"emptyLinePlaceholder":267},true,"\n",[40,270,272,276,279],{"class":42,"line":271},4,[40,273,275],{"class":274},"sP0c6","REPORTS",[40,277,278],{"class":237}," =",[40,280,281],{"class":241}," {\n",[40,283,285,288,291,294,297,300,303,306,309,313,316,319],{"class":42,"line":284},5,[40,286,287],{"class":50},"    \"weekly_sales.xlsx\"",[40,289,290],{"class":241},":   {",[40,292,293],{"class":50},"\"cron\"",[40,295,296],{"class":241},": ",[40,298,299],{"class":50},"\"0 7 * * 1\"",[40,301,302],{"class":241},",   ",[40,304,305],{"class":50},"\"grace\"",[40,307,308],{"class":241},": timedelta(",[40,310,312],{"class":311},"sa561","hours",[40,314,315],{"class":237},"=",[40,317,318],{"class":274},"2",[40,320,321],{"class":241},")},\n",[40,323,325,328,331,333,335,338,341,343,345,347,349,352],{"class":42,"line":324},6,[40,326,327],{"class":50},"    \"daily_stock.xlsx\"",[40,329,330],{"class":241},":    {",[40,332,293],{"class":50},[40,334,296],{"class":241},[40,336,337],{"class":50},"\"30 6 * * 1-5\"",[40,339,340],{"class":241},", ",[40,342,305],{"class":50},[40,344,308],{"class":241},[40,346,312],{"class":311},[40,348,315],{"class":237},[40,350,351],{"class":274},"1",[40,353,321],{"class":241},[40,355,357,360,363,365,367,370,372,374,376,379,381,384],{"class":42,"line":356},7,[40,358,359],{"class":50},"    \"month_end_pack.xlsx\"",[40,361,362],{"class":241},": {",[40,364,293],{"class":50},[40,366,296],{"class":241},[40,368,369],{"class":50},"\"0 8 1 * *\"",[40,371,302],{"class":241},[40,373,305],{"class":50},[40,375,308],{"class":241},[40,377,378],{"class":311},"days",[40,380,315],{"class":237},[40,382,383],{"class":274},"3",[40,385,321],{"class":241},[40,387,389],{"class":42,"line":388},8,[40,390,391],{"class":241},"}\n",[40,393,395,398,400,403,406,408,411,413,416,418,421,423,425],{"class":42,"line":394},9,[40,396,397],{"class":241},"check_time ",[40,399,315],{"class":237},[40,401,402],{"class":241}," datetime(",[40,404,405],{"class":274},"2026",[40,407,340],{"class":241},[40,409,410],{"class":274},"9",[40,412,340],{"class":241},[40,414,415],{"class":274},"29",[40,417,340],{"class":241},[40,419,420],{"class":274},"10",[40,422,340],{"class":241},[40,424,94],{"class":274},[40,426,427],{"class":241},")\n",[40,429,431],{"class":42,"line":430},10,[40,432,268],{"emptyLinePlaceholder":267},[40,434,436,439,442,445,448],{"class":42,"line":435},11,[40,437,438],{"class":237},"for",[40,440,441],{"class":241}," name, spec ",[40,443,444],{"class":237},"in",[40,446,447],{"class":274}," REPORTS",[40,449,450],{"class":241},".items():\n",[40,452,454,457,459,462,464],{"class":42,"line":453},12,[40,455,456],{"class":241},"    due ",[40,458,315],{"class":237},[40,460,461],{"class":241}," croniter(spec[",[40,463,293],{"class":50},[40,465,466],{"class":241},"], check_time).get_prev(datetime)\n",[40,468,470,473,476,479,482,486,489,492,495,498,500,503,506,509,512,514,517,519,522,524,527,530,533,535,538,540,543,546,549,552,555,557,559,561,563,565,567,569,572,574,576,578,580],{"class":42,"line":469},13,[40,471,472],{"class":274},"    print",[40,474,475],{"class":241},"(",[40,477,478],{"class":237},"f",[40,480,481],{"class":50},"\"",[40,483,485],{"class":484},"sSjpA","{",[40,487,488],{"class":241},"name",[40,490,491],{"class":237},":20",[40,493,494],{"class":484},"}",[40,496,497],{"class":50}," last due ",[40,499,485],{"class":484},[40,501,502],{"class":241},"due",[40,504,505],{"class":237},":",[40,507,508],{"class":241},"%",[40,510,511],{"class":237},"a ",[40,513,508],{"class":241},[40,515,516],{"class":237},"d ",[40,518,508],{"class":241},[40,520,521],{"class":237},"b ",[40,523,508],{"class":241},[40,525,526],{"class":237},"H",[40,528,529],{"class":241},":%",[40,531,532],{"class":237},"M",[40,534,494],{"class":484},[40,536,537],{"class":50},", overdue after ",[40,539,485],{"class":484},[40,541,542],{"class":241},"due ",[40,544,545],{"class":237},"+",[40,547,548],{"class":241}," spec[",[40,550,551],{"class":50},"'grace'",[40,553,554],{"class":241},"]:",[40,556,508],{"class":237},[40,558,511],{"class":241},[40,560,508],{"class":237},[40,562,516],{"class":241},[40,564,508],{"class":237},[40,566,521],{"class":241},[40,568,508],{"class":237},[40,570,571],{"class":241},"H:",[40,573,508],{"class":237},[40,575,532],{"class":241},[40,577,494],{"class":484},[40,579,481],{"class":50},[40,581,427],{"class":241},[10,583,66,584,587],{},[14,585,586],{},"grace"," period is how late a report may be before it counts as missing: the weekly report normally lands within minutes, so two hours is generous; the month-end pack depends on people closing the books, so it gets three days. Keeping the schedule in the same form as the scheduler's — ideally read from the same configuration — avoids the check and the job drifting apart.",[25,589,591],{"id":590},"step-2-compare-each-output-with-its-schedule","Step 2 — Compare each output with its schedule",[10,593,594],{},"A report is fresh if its output was written after it was last due. If it is older and the grace period has passed, the run was missed:",[30,596,598],{"className":228,"code":597,"language":230,"meta":35,"style":35},"from pathlib import Path\n\ndef freshness(folder, reports, at):\n    rows = []\n    for name, spec in reports.items():\n        due = croniter(spec[\"cron\"], at).get_prev(datetime)\n        path = Path(folder) \u002F name\n        written = datetime.fromtimestamp(path.stat().st_mtime) if path.exists() else None\n        if written and written >= due:\n            status = \"fresh\"\n        elif at \u003C due + spec[\"grace\"]:\n            status = \"due — within grace\"\n        else:\n            status = \"MISSED\"\n        rows.append({\"report\": name, \"last_due\": due, \"written\": written, \"status\": status})\n    return pd.DataFrame(rows)\n\nstatus = freshness(\"share\", REPORTS, check_time)\nprint(status.to_string(index=False))\n",[37,599,600,612,616,628,638,650,664,680,702,721,731,754,763,771,781,811,820,825,846],{"__ignoreMap":35},[40,601,602,604,607,609],{"class":42,"line":43},[40,603,238],{"class":237},[40,605,606],{"class":241}," pathlib ",[40,608,245],{"class":237},[40,610,611],{"class":241}," Path\n",[40,613,614],{"class":42,"line":251},[40,615,268],{"emptyLinePlaceholder":267},[40,617,618,621,625],{"class":42,"line":264},[40,619,620],{"class":237},"def",[40,622,624],{"class":623},"s_Opv"," freshness",[40,626,627],{"class":241},"(folder, reports, at):\n",[40,629,630,633,635],{"class":42,"line":271},[40,631,632],{"class":241},"    rows ",[40,634,315],{"class":237},[40,636,637],{"class":241}," []\n",[40,639,640,643,645,647],{"class":42,"line":284},[40,641,642],{"class":237},"    for",[40,644,441],{"class":241},[40,646,444],{"class":237},[40,648,649],{"class":241}," reports.items():\n",[40,651,652,655,657,659,661],{"class":42,"line":324},[40,653,654],{"class":241},"        due ",[40,656,315],{"class":237},[40,658,461],{"class":241},[40,660,293],{"class":50},[40,662,663],{"class":241},"], at).get_prev(datetime)\n",[40,665,666,669,671,674,677],{"class":42,"line":356},[40,667,668],{"class":241},"        path ",[40,670,315],{"class":237},[40,672,673],{"class":241}," Path(folder) ",[40,675,676],{"class":237},"\u002F",[40,678,679],{"class":241}," name\n",[40,681,682,685,687,690,693,696,699],{"class":42,"line":388},[40,683,684],{"class":241},"        written ",[40,686,315],{"class":237},[40,688,689],{"class":241}," datetime.fromtimestamp(path.stat().st_mtime) ",[40,691,692],{"class":237},"if",[40,694,695],{"class":241}," path.exists() ",[40,697,698],{"class":237},"else",[40,700,701],{"class":274}," None\n",[40,703,704,707,710,713,715,718],{"class":42,"line":394},[40,705,706],{"class":237},"        if",[40,708,709],{"class":241}," written ",[40,711,712],{"class":237},"and",[40,714,709],{"class":241},[40,716,717],{"class":237},">=",[40,719,720],{"class":241}," due:\n",[40,722,723,726,728],{"class":42,"line":430},[40,724,725],{"class":241},"            status ",[40,727,315],{"class":237},[40,729,730],{"class":50}," \"fresh\"\n",[40,732,733,736,739,742,745,747,749,751],{"class":42,"line":435},[40,734,735],{"class":237},"        elif",[40,737,738],{"class":241}," at ",[40,740,741],{"class":237},"\u003C",[40,743,744],{"class":241}," due ",[40,746,545],{"class":237},[40,748,548],{"class":241},[40,750,305],{"class":50},[40,752,753],{"class":241},"]:\n",[40,755,756,758,760],{"class":42,"line":453},[40,757,725],{"class":241},[40,759,315],{"class":237},[40,761,762],{"class":50}," \"due — within grace\"\n",[40,764,765,768],{"class":42,"line":469},[40,766,767],{"class":237},"        else",[40,769,770],{"class":241},":\n",[40,772,774,776,778],{"class":42,"line":773},14,[40,775,725],{"class":241},[40,777,315],{"class":237},[40,779,780],{"class":50}," \"MISSED\"\n",[40,782,784,787,790,793,796,799,802,805,808],{"class":42,"line":783},15,[40,785,786],{"class":241},"        rows.append({",[40,788,789],{"class":50},"\"report\"",[40,791,792],{"class":241},": name, ",[40,794,795],{"class":50},"\"last_due\"",[40,797,798],{"class":241},": due, ",[40,800,801],{"class":50},"\"written\"",[40,803,804],{"class":241},": written, ",[40,806,807],{"class":50},"\"status\"",[40,809,810],{"class":241},": status})\n",[40,812,814,817],{"class":42,"line":813},16,[40,815,816],{"class":237},"    return",[40,818,819],{"class":241}," pd.DataFrame(rows)\n",[40,821,823],{"class":42,"line":822},17,[40,824,268],{"emptyLinePlaceholder":267},[40,826,828,831,833,836,839,841,843],{"class":42,"line":827},18,[40,829,830],{"class":241},"status ",[40,832,315],{"class":237},[40,834,835],{"class":241}," freshness(",[40,837,838],{"class":50},"\"share\"",[40,840,340],{"class":241},[40,842,275],{"class":274},[40,844,845],{"class":241},", check_time)\n",[40,847,849,852,855,858,860,863],{"class":42,"line":848},19,[40,850,851],{"class":274},"print",[40,853,854],{"class":241},"(status.to_string(",[40,856,857],{"class":311},"index",[40,859,315],{"class":237},[40,861,862],{"class":274},"False",[40,864,865],{"class":241},"))\n",[10,867,868],{},"The weekly report was written on Monday at 07:04, after it was due — fresh. The daily stock report was last written on Friday; it was due again at 06:30 today and the hour of grace has passed, so today's run was missed, and yesterday's must have been too. The month-end pack from 1 September is fine until its next due date on 1 October. Using the file's modification time works for files on a share; for cloud storage, use the object's update time, and for emailed reports, the send time the job logs.",[72,870,81,875,81,878,81,881,81,884,81,891,81,896,81,903,81,909,81,914,81,918,81,922,81,928,81,934,81,940,81,944,81,946,81,948,81,952,81,959,81,964,81,967,81,969,81,971,81,974,81,979,81,983,81,986,81,988],{"viewBox":871,"role":75,"ariaLabelledBy":872,"xmlns":79,"style":80},"4 0 752 279",[873,874],"dr-daily-t","dr-daily-d",[83,876,877],{"id":873},"The daily stock report, as the check sees it",[87,879,880],{"id":874},"A timeline. The last output was written on Friday 25 September at 06:33. The report was due on Monday at 06:30 and on Tuesday at 06:30; the grace period for Tuesday ended at 07:30, so at 10:00 the check reports it as missed.",[91,882],{"x":93,"y":94,"width":95,"height":883,"fill":97},"279",[42,885],{"x1":886,"y1":887,"x2":888,"y2":887,"stroke":889,"style":890},"94.0","139.5","666.0","var(--muted,#5b6780)","stroke-width:2.5px",[91,892],{"x":100,"y":101,"width":893,"height":894,"rx":895,"fill":169,"stroke":144,"style":107},"172.0","91.5","12",[109,897,899],{"transform":898},"translate(94.0,29.0) scale(1.000)",[113,900],{"d":901,"fill":116,"stroke":176,"style":902},"M6 3h9l4 4v14H6z M15 3v4h4 M9.5 11.5l4.5 6 M14 11.5l-4.5 6","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[120,904,908],{"x":905,"y":906,"style":907},"106.0","74.5","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Fri 06:33",[120,910,913],{"x":905,"y":911,"style":912},"93.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","last output written",[42,915],{"x1":905,"y1":916,"x2":905,"y2":887,"stroke":889,"style":917},"107.5","stroke-width:1.5px",[919,920],"circle",{"cx":905,"cy":887,"r":921,"fill":144,"stroke":97,"style":107},"7.0",[91,923],{"x":924,"y":925,"width":893,"height":894,"rx":895,"fill":926,"stroke":927,"style":107},"202.7","171.5","#fce9e9","var(--danger,#dc2626)",[109,929,931],{"transform":930},"translate(276.7,184.5) scale(1.000)",[113,932],{"d":164,"fill":116,"stroke":933,"style":902},"var(--accent-ink,#be185d)",[120,935,939],{"x":936,"y":937,"style":938},"288.7","230.0","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Mon 06:30",[120,941,943],{"x":936,"y":942,"style":912},"248.5","due — nothing arrived",[42,945],{"x1":936,"y1":887,"x2":936,"y2":925,"stroke":889,"style":917},[919,947],{"cx":936,"cy":887,"r":921,"fill":927,"stroke":97,"style":107},[91,949],{"x":950,"y":101,"width":893,"height":894,"rx":895,"fill":951,"stroke":165,"style":107},"385.3","#fdefd8",[109,953,955],{"transform":954},"translate(459.3,29.0) scale(1.000)",[113,956],{"d":957,"fill":116,"stroke":958,"style":902},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2","var(--gold-ink,#7a4e06)",[120,960,963],{"x":961,"y":906,"style":962},"471.3","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Tue 07:30",[120,965,966],{"x":961,"y":911,"style":912},"grace period ends",[42,968],{"x1":961,"y1":916,"x2":961,"y2":887,"stroke":889,"style":917},[919,970],{"cx":961,"cy":887,"r":921,"fill":165,"stroke":97,"style":107},[91,972],{"x":973,"y":925,"width":893,"height":894,"rx":895,"fill":926,"stroke":927,"style":107},"568.0",[109,975,977],{"transform":976},"translate(642.0,184.5) scale(1.000)",[113,978],{"d":164,"fill":116,"stroke":933,"style":902},[120,980,982],{"x":981,"y":937,"style":938},"654.0","Tue 10:00",[120,984,985],{"x":981,"y":942,"style":912},"check: MISSED",[42,987],{"x1":981,"y1":887,"x2":981,"y2":925,"stroke":889,"style":917},[919,989],{"cx":981,"cy":887,"r":921,"fill":927,"stroke":97,"style":107},[25,991,993],{"id":992},"step-3-run-the-check-somewhere-else-and-alert","Step 3 — Run the check somewhere else, and alert",[10,995,996],{},"The check must not run on the same machine, in the same crontab or the same cluster namespace as the reports — otherwise the event that stops the reports stops the check too. Run it from a different scheduler, and send an alert only for missed reports:",[30,998,1000],{"className":228,"code":999,"language":230,"meta":35,"style":35},"def alert_text(status_table):\n    missed = status_table[status_table[\"status\"] == \"MISSED\"]\n    if missed.empty:\n        return None\n    lines = [f\"- {r.report}: last due {r.last_due:%a %d %b %H:%M}, last written \"\n             f\"{r.written:%a %d %b %H:%M}\" if r.written else f\"- {r.report}: never written\"\n             for r in missed.itertuples()]\n    return \"Scheduled reports missing:\\n\" + \"\\n\".join(lines)\n\nprint(alert_text(status))\n",[37,1001,1002,1012,1036,1044,1051,1106,1166,1179,1204,1208],{"__ignoreMap":35},[40,1003,1004,1006,1009],{"class":42,"line":43},[40,1005,620],{"class":237},[40,1007,1008],{"class":623}," alert_text",[40,1010,1011],{"class":241},"(status_table):\n",[40,1013,1014,1017,1019,1022,1024,1027,1030,1033],{"class":42,"line":251},[40,1015,1016],{"class":241},"    missed ",[40,1018,315],{"class":237},[40,1020,1021],{"class":241}," status_table[status_table[",[40,1023,807],{"class":50},[40,1025,1026],{"class":241},"] ",[40,1028,1029],{"class":237},"==",[40,1031,1032],{"class":50}," \"MISSED\"",[40,1034,1035],{"class":241},"]\n",[40,1037,1038,1041],{"class":42,"line":264},[40,1039,1040],{"class":237},"    if",[40,1042,1043],{"class":241}," missed.empty:\n",[40,1045,1046,1049],{"class":42,"line":271},[40,1047,1048],{"class":237},"        return",[40,1050,701],{"class":274},[40,1052,1053,1056,1058,1061,1063,1066,1068,1071,1073,1076,1078,1081,1083,1085,1087,1089,1091,1093,1095,1097,1099,1101,1103],{"class":42,"line":284},[40,1054,1055],{"class":241},"    lines ",[40,1057,315],{"class":237},[40,1059,1060],{"class":241}," [",[40,1062,478],{"class":237},[40,1064,1065],{"class":50},"\"- ",[40,1067,485],{"class":484},[40,1069,1070],{"class":241},"r.report",[40,1072,494],{"class":484},[40,1074,1075],{"class":50},": last due ",[40,1077,485],{"class":484},[40,1079,1080],{"class":241},"r.last_due:",[40,1082,508],{"class":237},[40,1084,511],{"class":241},[40,1086,508],{"class":237},[40,1088,516],{"class":241},[40,1090,508],{"class":237},[40,1092,521],{"class":241},[40,1094,508],{"class":237},[40,1096,571],{"class":241},[40,1098,508],{"class":237},[40,1100,532],{"class":241},[40,1102,494],{"class":484},[40,1104,1105],{"class":50},", last written \"\n",[40,1107,1108,1111,1113,1115,1118,1120,1122,1124,1126,1128,1130,1132,1134,1136,1138,1140,1142,1144,1147,1150,1152,1155,1157,1159,1161,1163],{"class":42,"line":324},[40,1109,1110],{"class":237},"             f",[40,1112,481],{"class":50},[40,1114,485],{"class":484},[40,1116,1117],{"class":241},"r.written",[40,1119,505],{"class":237},[40,1121,508],{"class":241},[40,1123,511],{"class":237},[40,1125,508],{"class":241},[40,1127,516],{"class":237},[40,1129,508],{"class":241},[40,1131,521],{"class":237},[40,1133,508],{"class":241},[40,1135,526],{"class":237},[40,1137,529],{"class":241},[40,1139,532],{"class":237},[40,1141,494],{"class":484},[40,1143,481],{"class":50},[40,1145,1146],{"class":237}," if",[40,1148,1149],{"class":241}," r.written ",[40,1151,698],{"class":237},[40,1153,1154],{"class":237}," f",[40,1156,1065],{"class":50},[40,1158,485],{"class":484},[40,1160,1070],{"class":241},[40,1162,494],{"class":484},[40,1164,1165],{"class":50},": never written\"\n",[40,1167,1168,1171,1174,1176],{"class":42,"line":356},[40,1169,1170],{"class":237},"             for",[40,1172,1173],{"class":241}," r ",[40,1175,444],{"class":237},[40,1177,1178],{"class":241}," missed.itertuples()]\n",[40,1180,1181,1183,1186,1189,1191,1194,1197,1199,1201],{"class":42,"line":388},[40,1182,816],{"class":237},[40,1184,1185],{"class":50}," \"Scheduled reports missing:",[40,1187,1188],{"class":484},"\\n",[40,1190,481],{"class":50},[40,1192,1193],{"class":237}," +",[40,1195,1196],{"class":50}," \"",[40,1198,1188],{"class":484},[40,1200,481],{"class":50},[40,1202,1203],{"class":241},".join(lines)\n",[40,1205,1206],{"class":42,"line":394},[40,1207,268],{"emptyLinePlaceholder":267},[40,1209,1210,1212],{"class":42,"line":430},[40,1211,851],{"class":274},[40,1213,1214],{"class":241},"(alert_text(status))\n",[10,1216,1217,1218,1222],{},"The message names each missing report, when it was due and when it last appeared — enough for whoever receives it to know where to look. Sending it uses whatever channel the team watches; ",[19,1219,1221],{"href":1220},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails\u002F","Send an Alert When an Excel Report Job Fails"," covers email, Slack and Teams.",[25,1224,1226],{"id":1225},"step-4-heartbeats-to-an-external-monitor","Step 4 — Heartbeats to an external monitor",[10,1228,1229],{},"The second approach inverts the check: each job pings a monitoring service when it starts and when it finishes, and the service — configured with the job's schedule and grace — alerts when a ping does not arrive. Hosted services such as Healthchecks.io, Cronitor and Better Stack work this way, and Healthchecks is also open source for self-hosting. The job side is a few HTTP calls, sent in a way that can never break the report itself:",[30,1231,1233],{"className":228,"code":1232,"language":230,"meta":35,"style":35},"import requests\n\ndef ping(base_url, signal=\"\", timeout=5):\n    try:\n        requests.get(f\"{base_url}{'\u002F' + signal if signal else ''}\", timeout=timeout)\n    except requests.RequestException:\n        pass                                # monitoring must never fail the job\n\ndef run_with_heartbeat(base_url, job):\n    ping(base_url, \"start\")\n    try:\n        job()\n    except Exception:\n        ping(base_url, \"fail\")\n        raise\n    ping(base_url)\n",[37,1234,1235,1241,1245,1271,1278,1326,1334,1343,1347,1357,1367,1373,1378,1387,1397,1402],{"__ignoreMap":35},[40,1236,1237,1239],{"class":42,"line":43},[40,1238,245],{"class":237},[40,1240,63],{"class":241},[40,1242,1243],{"class":42,"line":251},[40,1244,268],{"emptyLinePlaceholder":267},[40,1246,1247,1249,1252,1255,1257,1260,1263,1265,1268],{"class":42,"line":264},[40,1248,620],{"class":237},[40,1250,1251],{"class":623}," ping",[40,1253,1254],{"class":241},"(base_url, signal",[40,1256,315],{"class":237},[40,1258,1259],{"class":50},"\"\"",[40,1261,1262],{"class":241},", timeout",[40,1264,315],{"class":237},[40,1266,1267],{"class":274},"5",[40,1269,1270],{"class":241},"):\n",[40,1272,1273,1276],{"class":42,"line":271},[40,1274,1275],{"class":237},"    try",[40,1277,770],{"class":241},[40,1279,1280,1283,1285,1287,1289,1292,1295,1298,1300,1303,1305,1307,1309,1312,1314,1316,1318,1321,1323],{"class":42,"line":284},[40,1281,1282],{"class":241},"        requests.get(",[40,1284,478],{"class":237},[40,1286,481],{"class":50},[40,1288,485],{"class":484},[40,1290,1291],{"class":241},"base_url",[40,1293,1294],{"class":484},"}{",[40,1296,1297],{"class":50},"'\u002F'",[40,1299,1193],{"class":237},[40,1301,1302],{"class":241}," signal ",[40,1304,692],{"class":237},[40,1306,1302],{"class":241},[40,1308,698],{"class":237},[40,1310,1311],{"class":50}," ''",[40,1313,494],{"class":484},[40,1315,481],{"class":50},[40,1317,340],{"class":241},[40,1319,1320],{"class":311},"timeout",[40,1322,315],{"class":237},[40,1324,1325],{"class":241},"timeout)\n",[40,1327,1328,1331],{"class":42,"line":324},[40,1329,1330],{"class":237},"    except",[40,1332,1333],{"class":241}," requests.RequestException:\n",[40,1335,1336,1339],{"class":42,"line":356},[40,1337,1338],{"class":237},"        pass",[40,1340,1342],{"class":1341},"s-wDw","                                # monitoring must never fail the job\n",[40,1344,1345],{"class":42,"line":388},[40,1346,268],{"emptyLinePlaceholder":267},[40,1348,1349,1351,1354],{"class":42,"line":394},[40,1350,620],{"class":237},[40,1352,1353],{"class":623}," run_with_heartbeat",[40,1355,1356],{"class":241},"(base_url, job):\n",[40,1358,1359,1362,1365],{"class":42,"line":430},[40,1360,1361],{"class":241},"    ping(base_url, ",[40,1363,1364],{"class":50},"\"start\"",[40,1366,427],{"class":241},[40,1368,1369,1371],{"class":42,"line":435},[40,1370,1275],{"class":237},[40,1372,770],{"class":241},[40,1374,1375],{"class":42,"line":453},[40,1376,1377],{"class":241},"        job()\n",[40,1379,1380,1382,1385],{"class":42,"line":469},[40,1381,1330],{"class":237},[40,1383,1384],{"class":274}," Exception",[40,1386,770],{"class":241},[40,1388,1389,1392,1395],{"class":42,"line":773},[40,1390,1391],{"class":241},"        ping(base_url, ",[40,1393,1394],{"class":50},"\"fail\"",[40,1396,427],{"class":241},[40,1398,1399],{"class":42,"line":783},[40,1400,1401],{"class":237},"        raise\n",[40,1403,1404],{"class":42,"line":813},[40,1405,1406],{"class":241},"    ping(base_url)\n",[10,1408,66,1409,1412,1413,1416],{},[37,1410,1411],{},"start"," signal lets the monitor measure run time and notice a job that started but hung; the bare ping marks success; ",[37,1414,1415],{},"fail"," reports a failure at once instead of waiting for the grace period. Each report gets its own check URL, which acts as a secret — anyone who has it can report success — so keep it in the job's configuration, not in the code.",[30,1418,1420],{"className":228,"code":1419,"language":230,"meta":35,"style":35},"run_with_heartbeat(MONITOR, lambda: None)\ntry:\n    run_with_heartbeat(MONITOR, lambda: 1 \u002F 0)\nexcept ZeroDivisionError:\n    pass\nping(\"http:\u002F\u002F127.0.0.1:9\u002Funreachable\")       # monitor down: the job carries on\nprint(received)\n",[37,1421,1422,1442,1449,1472,1482,1487,1501],{"__ignoreMap":35},[40,1423,1424,1427,1430,1432,1435,1437,1440],{"class":42,"line":43},[40,1425,1426],{"class":241},"run_with_heartbeat(",[40,1428,1429],{"class":274},"MONITOR",[40,1431,340],{"class":241},[40,1433,1434],{"class":237},"lambda",[40,1436,296],{"class":241},[40,1438,1439],{"class":274},"None",[40,1441,427],{"class":241},[40,1443,1444,1447],{"class":42,"line":251},[40,1445,1446],{"class":237},"try",[40,1448,770],{"class":241},[40,1450,1451,1454,1456,1458,1460,1462,1464,1467,1470],{"class":42,"line":264},[40,1452,1453],{"class":241},"    run_with_heartbeat(",[40,1455,1429],{"class":274},[40,1457,340],{"class":241},[40,1459,1434],{"class":237},[40,1461,296],{"class":241},[40,1463,351],{"class":274},[40,1465,1466],{"class":237}," \u002F",[40,1468,1469],{"class":274}," 0",[40,1471,427],{"class":241},[40,1473,1474,1477,1480],{"class":42,"line":271},[40,1475,1476],{"class":237},"except",[40,1478,1479],{"class":274}," ZeroDivisionError",[40,1481,770],{"class":241},[40,1483,1484],{"class":42,"line":284},[40,1485,1486],{"class":237},"    pass\n",[40,1488,1489,1492,1495,1498],{"class":42,"line":324},[40,1490,1491],{"class":241},"ping(",[40,1493,1494],{"class":50},"\"http:\u002F\u002F127.0.0.1:9\u002Funreachable\"",[40,1496,1497],{"class":241},")       ",[40,1499,1500],{"class":1341},"# monitor down: the job carries on\n",[40,1502,1503,1505],{"class":42,"line":356},[40,1504,851],{"class":274},[40,1506,1507],{"class":241},"(received)\n",[10,1509,1510,1511,1513,1514,1516,1517,1519,1520,1523],{},"A successful run sends ",[37,1512,1411],{}," and then the success ping; a failing one sends ",[37,1515,1411],{}," and ",[37,1518,1415],{},", and the exception still propagates, so the scheduler records the failure too. With the monitoring service unreachable, ",[37,1521,1522],{},"ping"," gives up quietly after its timeout — a report must not fail because the thing watching it is down.",[72,1525,81,1530,81,1533,81,1536,81,1539,81,1543,81,1549,81,1554,81,1559,81,1562,81,1566,81,1571,81,1573,81,1575,81,1580,81,1585,81,1592,81,1598,81,1601,81,1604,81,1609,81,1613,81,1618,81,1621,81,1626],{"viewBox":1526,"role":75,"ariaLabelledBy":1527,"xmlns":79,"style":80},"4 0 752 244",[1528,1529],"dr-heartbeat-t","dr-heartbeat-d",[83,1531,1532],{"id":1528},"Heartbeats from one run",[87,1534,1535],{"id":1529},"A sequence diagram. The report job pings the monitor with start, builds the workbook, and pings success; if no success ping arrives within the schedule plus grace, the monitor alerts the team.",[91,1537],{"x":93,"y":94,"width":95,"height":1538,"fill":97},"244",[91,1540],{"x":100,"y":101,"width":1541,"height":1542,"rx":895,"fill":105,"stroke":106,"style":107},"180.0","48.0",[120,1544,1548],{"x":1545,"y":1546,"style":1547},"110.0","44.5","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","report job",[91,1550],{"x":1551,"y":101,"width":1541,"height":1542,"rx":895,"fill":1552,"stroke":1553,"style":107},"290.0","#e7ebef","var(--line,#cdd5e6)",[120,1555,1558],{"x":1556,"y":1546,"style":1557},"380.0","font-size:13px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","monitor",[91,1560],{"x":1561,"y":101,"width":1541,"height":1542,"rx":895,"fill":1552,"stroke":1553,"style":107},"560.0",[120,1563,1565],{"x":1564,"y":1546,"style":1557},"650.0","team",[42,1567],{"x1":1545,"y1":1568,"x2":1545,"y2":1569,"stroke":1553,"style":1570},"64.0","228.0","stroke-width:1.5px;stroke-dasharray:5 4",[42,1572],{"x1":1556,"y1":1568,"x2":1556,"y2":1569,"stroke":1553,"style":1570},[42,1574],{"x1":1564,"y1":1568,"x2":1564,"y2":1569,"stroke":1553,"style":1570},[42,1576],{"x1":1577,"y1":1578,"x2":1579,"y2":1578,"stroke":106,"style":107},"115.0","104.0","371.0",[1581,1582],"polygon",{"points":1583,"fill":1584},"375.0,104.0 366.0,109.0 366.0,99.0","#5b5cf0",[91,1586],{"x":1587,"y":1588,"width":1589,"height":1590,"rx":420,"fill":97,"stroke":1553,"style":1591},"194.1","75.0","101.7","21.0","stroke-width:1.2px",[120,1593,1597],{"x":1594,"y":1595,"style":1596},"245.0","89.5","font-size:11.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","1. GET …\u002Fstart",[42,1599],{"x1":1577,"y1":1600,"x2":1579,"y2":1600,"stroke":106,"style":107},"152.0",[1581,1602],{"points":1603,"fill":1584},"375.0,152.0 366.0,157.0 366.0,147.0",[91,1605],{"x":1606,"y":1607,"width":1608,"height":1590,"rx":420,"fill":97,"stroke":1553,"style":1591},"178.5","123.0","133.1",[120,1610,1612],{"x":1594,"y":1611,"style":1596},"137.5","2. GET … (success)",[42,1614],{"x1":1615,"y1":1616,"x2":1617,"y2":1616,"stroke":106,"style":107},"385.0","200.0","641.0",[1581,1619],{"points":1620,"fill":1584},"645.0,200.0 636.0,205.0 636.0,195.0",[91,1622],{"x":1623,"y":1624,"width":1625,"height":1590,"rx":420,"fill":97,"stroke":1553,"style":1591},"387.1","171.0","255.8",[120,1627,1630],{"x":1628,"y":1629,"style":1596},"515.0","185.5","3. alert only if a ping is late or fail arrives",[25,1632,1634],{"id":1633},"step-5-a-freshness-sheet-for-humans","Step 5 — A freshness sheet for humans",[10,1636,1637],{},"The same table is worth publishing where people look: a small workbook or a sheet in the reporting folder listing every scheduled report, when it last arrived and whether it is current. It answers \"is Monday's report the latest?\" without asking anyone:",[30,1639,1641],{"className":228,"code":1640,"language":230,"meta":35,"style":35},"out = status.assign(last_due=status[\"last_due\"].dt.strftime(\"%a %d %b %H:%M\"),\n                    written=status[\"written\"].dt.strftime(\"%a %d %b %H:%M\"))\nout.columns = [\"Report\", \"Last due\", \"Last written\", \"Status\"]\nwith pd.ExcelWriter(\"share\u002Freport_freshness.xlsx\", engine=\"xlsxwriter\") as xw:\n    out.to_excel(xw, sheet_name=\"Freshness\", index=False)\n    red = xw.book.add_format({\"bg_color\": \"#FCE9E9\", \"font_color\": \"#9C0006\", \"bold\": True})\n    xw.sheets[\"Freshness\"].conditional_format(1, 3, len(out), 3, {\"type\": \"text\", \"criteria\": \"containing\",\n                                                                   \"value\": \"MISSED\", \"format\": red})\n    xw.sheets[\"Freshness\"].set_column(\"A:D\", 22)\nprint(out.to_string(index=False))\n",[37,1642,1643,1683,1708,1737,1767,1790,1831,1881,1899,1918],{"__ignoreMap":35},[40,1644,1645,1648,1650,1653,1656,1658,1661,1663,1666,1668,1671,1674,1677,1680],{"class":42,"line":43},[40,1646,1647],{"class":241},"out ",[40,1649,315],{"class":237},[40,1651,1652],{"class":241}," status.assign(",[40,1654,1655],{"class":311},"last_due",[40,1657,315],{"class":237},[40,1659,1660],{"class":241},"status[",[40,1662,795],{"class":50},[40,1664,1665],{"class":241},"].dt.strftime(",[40,1667,481],{"class":50},[40,1669,1670],{"class":484},"%a",[40,1672,1673],{"class":484}," %d",[40,1675,1676],{"class":484}," %b",[40,1678,1679],{"class":50}," %H:%M\"",[40,1681,1682],{"class":241},"),\n",[40,1684,1685,1688,1690,1692,1694,1696,1698,1700,1702,1704,1706],{"class":42,"line":251},[40,1686,1687],{"class":311},"                    written",[40,1689,315],{"class":237},[40,1691,1660],{"class":241},[40,1693,801],{"class":50},[40,1695,1665],{"class":241},[40,1697,481],{"class":50},[40,1699,1670],{"class":484},[40,1701,1673],{"class":484},[40,1703,1676],{"class":484},[40,1705,1679],{"class":50},[40,1707,865],{"class":241},[40,1709,1710,1713,1715,1717,1720,1722,1725,1727,1730,1732,1735],{"class":42,"line":264},[40,1711,1712],{"class":241},"out.columns ",[40,1714,315],{"class":237},[40,1716,1060],{"class":241},[40,1718,1719],{"class":50},"\"Report\"",[40,1721,340],{"class":241},[40,1723,1724],{"class":50},"\"Last due\"",[40,1726,340],{"class":241},[40,1728,1729],{"class":50},"\"Last written\"",[40,1731,340],{"class":241},[40,1733,1734],{"class":50},"\"Status\"",[40,1736,1035],{"class":241},[40,1738,1739,1742,1745,1748,1750,1753,1755,1758,1761,1764],{"class":42,"line":271},[40,1740,1741],{"class":237},"with",[40,1743,1744],{"class":241}," pd.ExcelWriter(",[40,1746,1747],{"class":50},"\"share\u002Freport_freshness.xlsx\"",[40,1749,340],{"class":241},[40,1751,1752],{"class":311},"engine",[40,1754,315],{"class":237},[40,1756,1757],{"class":50},"\"xlsxwriter\"",[40,1759,1760],{"class":241},") ",[40,1762,1763],{"class":237},"as",[40,1765,1766],{"class":241}," xw:\n",[40,1768,1769,1772,1775,1777,1780,1782,1784,1786,1788],{"class":42,"line":284},[40,1770,1771],{"class":241},"    out.to_excel(xw, ",[40,1773,1774],{"class":311},"sheet_name",[40,1776,315],{"class":237},[40,1778,1779],{"class":50},"\"Freshness\"",[40,1781,340],{"class":241},[40,1783,857],{"class":311},[40,1785,315],{"class":237},[40,1787,862],{"class":274},[40,1789,427],{"class":241},[40,1791,1792,1795,1797,1800,1803,1805,1808,1810,1813,1815,1818,1820,1823,1825,1828],{"class":42,"line":324},[40,1793,1794],{"class":241},"    red ",[40,1796,315],{"class":237},[40,1798,1799],{"class":241}," xw.book.add_format({",[40,1801,1802],{"class":50},"\"bg_color\"",[40,1804,296],{"class":241},[40,1806,1807],{"class":50},"\"#FCE9E9\"",[40,1809,340],{"class":241},[40,1811,1812],{"class":50},"\"font_color\"",[40,1814,296],{"class":241},[40,1816,1817],{"class":50},"\"#9C0006\"",[40,1819,340],{"class":241},[40,1821,1822],{"class":50},"\"bold\"",[40,1824,296],{"class":241},[40,1826,1827],{"class":274},"True",[40,1829,1830],{"class":241},"})\n",[40,1832,1833,1836,1838,1841,1843,1845,1847,1849,1852,1855,1857,1860,1863,1865,1868,1870,1873,1875,1878],{"class":42,"line":356},[40,1834,1835],{"class":241},"    xw.sheets[",[40,1837,1779],{"class":50},[40,1839,1840],{"class":241},"].conditional_format(",[40,1842,351],{"class":274},[40,1844,340],{"class":241},[40,1846,383],{"class":274},[40,1848,340],{"class":241},[40,1850,1851],{"class":274},"len",[40,1853,1854],{"class":241},"(out), ",[40,1856,383],{"class":274},[40,1858,1859],{"class":241},", {",[40,1861,1862],{"class":50},"\"type\"",[40,1864,296],{"class":241},[40,1866,1867],{"class":50},"\"text\"",[40,1869,340],{"class":241},[40,1871,1872],{"class":50},"\"criteria\"",[40,1874,296],{"class":241},[40,1876,1877],{"class":50},"\"containing\"",[40,1879,1880],{"class":241},",\n",[40,1882,1883,1886,1888,1891,1893,1896],{"class":42,"line":388},[40,1884,1885],{"class":50},"                                                                   \"value\"",[40,1887,296],{"class":241},[40,1889,1890],{"class":50},"\"MISSED\"",[40,1892,340],{"class":241},[40,1894,1895],{"class":50},"\"format\"",[40,1897,1898],{"class":241},": red})\n",[40,1900,1901,1903,1905,1908,1911,1913,1916],{"class":42,"line":394},[40,1902,1835],{"class":241},[40,1904,1779],{"class":50},[40,1906,1907],{"class":241},"].set_column(",[40,1909,1910],{"class":50},"\"A:D\"",[40,1912,340],{"class":241},[40,1914,1915],{"class":274},"22",[40,1917,427],{"class":241},[40,1919,1920,1922,1925,1927,1929,1931],{"class":42,"line":430},[40,1921,851],{"class":274},[40,1923,1924],{"class":241},"(out.to_string(",[40,1926,857],{"class":311},[40,1928,315],{"class":237},[40,1930,862],{"class":274},[40,1932,865],{"class":241},[25,1934,1936],{"id":1935},"common-pitfalls","Common pitfalls",[1938,1939,1940,1948,1954,1960,1966],"ul",{},[1941,1942,1943,1947],"li",{},[1944,1945,1946],"strong",{},"Monitoring from the same place as the job."," Whatever stops the job stops the check; run it elsewhere.",[1941,1949,1950,1953],{},[1944,1951,1952],{},"No grace period."," Reports finish a few minutes after their start time; alerting at the scheduled minute produces noise.",[1941,1955,1956,1959],{},[1944,1957,1958],{},"Heartbeat failures that fail the job."," Wrap pings so an unreachable monitor is ignored.",[1941,1961,1962,1965],{},[1944,1963,1964],{},"Only checking for errors."," A job that never starts raises no error; check for presence.",[1941,1967,1968,1971],{},[1944,1969,1970],{},"Schedules defined twice."," Read the cron expression from the same configuration as the scheduler, so a changed schedule does not trigger false alarms.",[25,1973,1975],{"id":1974},"conclusion","Conclusion",[10,1977,1978,1979,1982,1983,1985,1986,1988],{},"For every scheduled report, compute when it was last due with ",[37,1980,1981],{},"croniter(...).get_prev()",", compare that with when its output was last written, and flag it as missed once a grace period has passed — from a check that runs somewhere other than the reports. Add heartbeat pings to an external monitor, with ",[37,1984,1411],{},", success and ",[37,1987,1415],{}," signals sent so that a monitoring outage can never break the report, and publish the freshness table where the reports' readers can see it.",[25,1990,1992],{"id":1991},"frequently-asked-questions","Frequently asked questions",[10,1994,1995,1998],{},[1944,1996,1997],{},"How do I know if a cron job did not run?","\nError handling inside the job cannot tell you, because a job that never starts never reports. Check its output against the schedule from a separate process, or have the job ping an external monitor that alerts when a ping is late.",[10,2000,2001,2004],{},[1944,2002,2003],{},"What is a dead man's switch for scheduled jobs?","\nA monitor that expects a regular signal from a job and raises an alert when the signal does not arrive in time. The job pings on each successful run; silence means something is wrong.",[10,2006,2007,2010],{},[1944,2008,2009],{},"How do I compute when a cron job last should have run in Python?","\nUse croniter with the job's cron expression and the current time and call get_prev(datetime). Add a grace period before treating the run as missed.",[10,2012,2013,2016],{},[1944,2014,2015],{},"Should heartbeat pings be able to fail my report?","\nNo. Wrap them in a try\u002Fexcept with a short timeout so that an unreachable monitoring service is ignored and the report carries on.",[25,2018,2020],{"id":2019},"related","Related",[1938,2022,2023,2029,2036,2043],{},[1941,2024,2025,2026,2028],{},"Up one level: ",[19,2027,22],{"href":21}," — making report jobs robust.",[1941,2030,2031,2035],{},[19,2032,2034],{"href":2033},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fchoose-a-scheduler-for-python-excel-reports\u002F","Choose a Scheduler for Python Excel Reports"," — how each scheduler handles missed runs.",[1941,2037,2038,2042],{},[19,2039,2041],{"href":2040},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-overlapping-runs-of-an-excel-report-job\u002F","Prevent Overlapping Runs of an Excel Report Job"," — the opposite problem: too many runs.",[1941,2044,2045,2049],{},[19,2046,2048],{"href":2047},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it\u002F","Validate an Excel Report Before Sending It"," — a run that happened but produced the wrong thing.",[2051,2052,2053],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":35,"searchDepth":251,"depth":251,"links":2055},[2056,2057,2058,2059,2060,2061,2062,2063,2064,2065],{"id":27,"depth":251,"text":28},{"id":213,"depth":251,"text":214},{"id":590,"depth":251,"text":591},{"id":992,"depth":251,"text":993},{"id":1225,"depth":251,"text":1226},{"id":1633,"depth":251,"text":1634},{"id":1935,"depth":251,"text":1936},{"id":1974,"depth":251,"text":1975},{"id":1991,"depth":251,"text":1992},{"id":2019,"depth":251,"text":2020},"2026-09-29","Catch the failure that error handling cannot: a report job that never started. Check each report's output against its schedule — the last time it should have run plus a grace period — and send heartbeat pings to an external monitor that alerts when a ping is late, with start, success and failure signals and a freshness sheet for humans.","md",[2070,2072,2074,2076],{"q":1997,"a":2071},"Error handling inside the job cannot tell you, because a job that never starts never reports. Check its output against the schedule from a separate process, or have the job ping an external monitor that alerts when a ping is late.",{"q":2003,"a":2073},"A monitor that expects a regular signal from a job and raises an alert when the signal does not arrive in time. The job pings on each successful run; silence means something is wrong.",{"q":2009,"a":2075},"Use croniter with the job's cron expression and the current time and call get_prev(datetime). Add a grace period before treating the run as missed.",{"q":2015,"a":2077},"No. Wrap them in a try\u002Fexcept with a short timeout so that an unreachable monitoring service is ignored and the report carries on.","detect a scheduled report that did not run",null,{"breadcrumb":2081},[2082,2084,2087],{"name":2083,"item":676},"Home",{"name":2085,"item":2086},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run",{"title":2090,"description":2091},"Alert When a Scheduled Excel Report Did Not Run (Python)","Detect missed report runs: compute the previous scheduled time with croniter, compare output file ages with a grace period, send dead man's switch heartbeat pings (start\u002Fsuccess\u002Ffail) to an external monitor, and publish a freshness summary.","detect-a-scheduled-report-that-did-not-run","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run\u002Findex",[2095,2096],"reliable-jobs","scheduling","how-to","tTbzlg0mbnZS4RMDM4CUQcOtCoPbDSzfcmtaeh_KOUY",[2100,2103],{"title":22,"path":2101,"stem":2102,"children":-1},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Findex",{"title":2104,"path":2105,"stem":2106,"children":-1},"Handle Permission Denied When Writing Excel in Python","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fhandle-permission-denied-when-writing-excel-in-python","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fhandle-permission-denied-when-writing-excel-in-python\u002Findex",[2108,2109],{"path":2101,"title":22},{"path":2110,"title":2085},"\u002Fautomating-reporting-workflows",[],[2113,2117,2120,2123,2126,2129],{"path":2114,"title":2115,"kind":2116},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts","Testing and Packaging Excel Automation Scripts","guide",{"path":2118,"title":2119,"kind":2097},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook","Block Large or Sensitive Excel Files with a Pre-commit Hook",{"path":2121,"title":2122,"kind":2097},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse","Build a Command-Line Tool for Excel Reports with argparse",{"path":2124,"title":2125,"kind":2097},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcompare-excel-output-to-a-golden-file-in-tests","Compare Excel Output to a Golden File in Tests",{"path":2127,"title":2128,"kind":2097},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory","Create Excel Test Fixtures in Memory for pytest",{"path":2130,"title":2131,"kind":2097},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver","Diff Excel Files in Git with a textconv Driver",1790972130026]