- What It Does
- How It Works
- Architecture
- Configuration
- Deployment
- Updating
- Permissions
- Multi-Subscription Enrollment
- CI/CD
- Development
- Cost
- Troubleshooting
- License
Az-Stamper solves a fundamental Azure governance problem: it's surprisingly hard to know who created a resource.
When you create a VM, storage account, or any other resource in Azure, you don't interact with the resource directly. Instead, your request goes through Azure Resource Manager (ARM) — the control plane that handles all resource operations. ARM authenticates your identity, validates your permissions, and executes the deployment. The actual creator information lives in ARM's activity log as a claim on the API call, but:
- Activity logs expire after 90 days — after that, the creator information is gone forever
- The creator isn't visible on the resource itself — you have to dig through logs to find it
- ARM deployments obscure the real caller — if a user deploys via a pipeline, the activity log shows the pipeline's Service Principal, not the human who triggered it
- There's no built-in "created by" property on Azure resources
Az-Stamper fixes this by intercepting the ARM event at creation time and writing the caller's identity directly onto the resource as tags — key-value labels that are permanently attached to the resource, visible in the portal, queryable in Azure Resource Graph, and included in billing exports.
When someone (or something) creates or modifies an Azure resource, Azure generates an event. Az-Stamper listens for these events and immediately stamps the resource with tags like:
| Tag | Example Value | What It Tells You |
|---|---|---|
| Creator | alice@contoso.com |
Who originally created this resource |
| CreatedOn | 2026-03-28T10:17:41Z |
When it was created |
| LastModifiedBy | bob@contoso.com |
Who last changed it |
| LastModifiedOn | 2026-03-28T14:32:15Z |
When it was last changed |
| StampedBy | Az-Stamper |
Confirms automated tagging is working |
The Creator and CreatedOn tags are set once and never overwritten — even if the resource is modified later, you always know who originally created it. The LastModifiedBy and LastModifiedOn tags update on every change, giving you a running record of who touched it last.
Az-Stamper uses three Azure services working together:
1. Someone creates a VM, storage account, or any Azure resource
↓
2. Azure Event Grid notices and sends a "ResourceWriteSuccess" event
↓
3. Az-Stamper (an Azure Function) receives the event, reads who
triggered it, and writes tags onto the resource
Azure Event Grid is a messaging service built into Azure. It watches for things happening in your subscription (resources created, deleted, modified) and can route those events to handlers. Az-Stamper registers as a handler for ResourceWriteSuccess events — the event type Azure fires whenever a resource is created or updated successfully.
Azure Functions is a serverless compute service — you deploy code that runs only when triggered, and you pay only for the time it executes. Az-Stamper uses a Consumption (Y1) plan, which means it scales to zero when idle (costing nothing) and spins up automatically when an event arrives. For a typical dev/test subscription, the monthly cost is effectively zero (well within Azure's free grant of 1 million executions/month).
Managed Identity is how the function authenticates to Azure without passwords or API keys. When deployed, the function app gets a system-assigned identity (like a service account) that Azure manages automatically. Bicep assigns this identity the permissions it needs: Tag Contributor to write tags, and Reader to look up who Service Principals are.
Azure Subscription
│
├─ Event Grid System Topic (watches for resource events)
│ │
│ └─ Event Subscription (filters ResourceWriteSuccess → sends to Function)
│
└─ Resource Group: rg-az-stamper-dev
│
├─ Function App (Consumption Y1, .NET 8, Linux)
│ └─ ResourceStamper function (Event Grid trigger)
│
├─ Storage Account (function runtime, managed identity auth)
├─ App Service Plan (Consumption — serverless)
├─ Application Insights (monitoring & logging)
└─ Log Analytics Workspace (centralized log storage)
All infrastructure is defined as Bicep templates (Azure's infrastructure-as-code language). This means the entire deployment is repeatable, version-controlled, and reviewable — no portal clicking required.
Tags are controlled entirely through the Function App's application settings (environment variables). You don't need to change any code to add, remove, or modify tags. Each tag requires two settings:
StamperConfig__TagMap__<TagName>__Value = <template or literal>
StamperConfig__TagMap__<TagName>__Overwrite = true | false
- Value is what gets written to the tag. It can be a literal string like
Az-Stamperor a template variable like{caller}. - Overwrite controls whether the tag updates on subsequent resource modifications. Set to
falsefor "set once" tags (like Creator),truefor "always update" tags (like LastModifiedBy).
These placeholders get replaced with real values when the tag is written:
| Variable | Resolves To | Example |
|---|---|---|
{caller} |
The identity that triggered the event | alice@contoso.com or MyServicePrincipal |
{timestamp} |
Current UTC time in ISO 8601 format | 2026-03-28T10:17:41Z |
{principalType} |
Whether the caller is a user or automation | User or ServicePrincipal |
| Anything else | Used as-is (literal value) | Az-Stamper, Finance, Production |
To add a Department tag that gets set once and never changes:
StamperConfig__TagMap__Department__Value = Engineering
StamperConfig__TagMap__Department__Overwrite = false
You can set these in the Azure Portal (Function App → Configuration → Application settings) or add them to the Bicep template in infra/modules/functionApp.bicep.
Some resource types should never be tagged. For example, tagging a "tag" resource would create an infinite loop (tag write → event → tag write → event...). The ignore list prevents this:
StamperConfig__IgnorePatterns__0 = Microsoft.Resources/deployments
StamperConfig__IgnorePatterns__1 = Microsoft.Resources/tags
StamperConfig__IgnorePatterns__2 = Microsoft.Network/frontdoor
If Az-Stamper is tagging a resource type you want to exclude, add another entry with the next index number (e.g., __3). The value is matched against the resource's provider path — for example, Microsoft.Compute/virtualMachines would skip all VMs.
When a user creates a resource, their UPN (e.g., alice@contoso.com) is embedded in the event and used directly. When automation (a Service Principal or Managed Identity) creates a resource, Az-Stamper attempts to look up its friendly display name via the Microsoft Graph API. If that fails (missing permissions), it falls back to the raw principal ID (a GUID). To enable display name resolution, grant the function's managed identity the Directory.Read.All Graph API permission (see Step 7 in deployment).
One click deploys the hub and enrolls the deploying subscription for tagging.
Prerequisites:
- An Azure subscription with Owner or Contributor + User Access Administrator role
- The
Microsoft.EventGridresource provider registered on the subscription (az provider register --namespace Microsoft.EventGrid)
- Click the Deploy Hub to Azure button
- The Azure portal opens a Custom deployment form. Fill in the required fields:
- Region — pick the Azure region closest to you (e.g., East US 2, West Europe)
- Resource Group Name — a name for the new resource group (default:
rg-az-stamper) - Storage Account Name — must be globally unique, 3-24 lowercase letters and numbers only (e.g.,
stazstamper42) - Function App Name — name for the function app (default:
func-az-stamper) - App Insights Name — name for Application Insights (default:
ai-az-stamper)
- Click Review + create, then Create
- Deployment takes 3-5 minutes. The hub infrastructure, function code, RBAC, and Event Grid enrollment all deploy automatically.
After both deployments complete, create a test resource and check for tags:
# Open Azure Cloud Shell (the terminal icon >_ in the portal header) and run:
# Create a test storage account (use your resource group name)
az storage account create \
--name stazstampertest$RANDOM \
--resource-group rg-az-stamper \
--sku Standard_LRS
# Wait 60-90 seconds for the event to flow through (first invocation has a cold start)
# Check the tags (replace the resource ID with yours from the create output)
az tag list --resource-id <resource-id-from-create-output>You should see all five tags: Creator, CreatedOn, LastModifiedBy, LastModifiedOn, StampedBy.
This is a defense-in-depth measure that prevents the function from processing its own tag operations. Event Grid filters already prevent recursive loops, so this step is optional.
Open Azure Cloud Shell (the >_ icon in the portal header) and run:
PRINCIPAL_ID=$(az functionapp identity show --name <your-function-app> --resource-group <your-rg> --query principalId -o tsv)
az functionapp config appsettings set --name <your-function-app> --resource-group <your-rg> --settings "StamperConfig__SelfPrincipalId=$PRINCIPAL_ID"After deployment, resources begin tagging automatically. To enroll additional subscriptions, see Multi-Subscription Enrollment.
Az-Stamper includes a browser-based config management UI for managing tag rules, simulating tag changes, and viewing activity. To enable it:
Prerequisites: PowerShell 7, Az module (Install-Module Az), Node.js (for npx)
# Connect to Azure (if not already)
pwsh -Command "Connect-AzAccount"
# Run the setup script
pwsh -File scripts/Setup-SwaAuth.ps1The script creates an Entra ID app registration, generates the config file, and deploys the UI. Open the URL printed at the end to access the config UI. You can re-run the script at any time to redeploy.
Az-Stamper runs in Azure Government (GCC High / DoD) from the same codebase — the templates are cloud-aware and resolve Gov endpoints automatically (core.usgovcloudapi.net, login.microsoftonline.us, management.usgovcloudapi.net, graph.microsoft.us) via Bicep's environment() function and matching app settings.
Two differences from commercial Azure:
- No Config UI. Azure Static Web Apps is not available in Azure Government, so deploy with
deploySwa=false. The core tagging engine is unaffected; manage tag rules via app settings / the config blob instead. - Deploy via CLI, not the portal button. The Deploy-to-Azure buttons target the commercial portal (
portal.azure.com). In Gov, deploy the Bicep directly.
# 1. Connect to Azure Government
Connect-AzAccount -Environment AzureUSGovernment
# (Azure CLI equivalent: az cloud set --name AzureUSGovernment ; az login)
# 2. Register resource providers on the target subscription
foreach ($rp in 'Microsoft.Web','Microsoft.EventGrid','Microsoft.Storage','Microsoft.Insights','Microsoft.OperationalInsights') {
az provider register --namespace $rp
}
# 3. Deploy the hub and enroll the deploying subscription (Config UI skipped)
az deployment sub create `
--location usgovvirginia `
--template-file infra/deploy.bicep `
--parameters storageAccountName='stazstampergov' `
functionAppName='func-az-stamper-gov' `
environment='prod' `
deploySwa=falseThen verify tagging end-to-end exactly as in Deploy to Azure → Step 2, and optionally grant the managed identity Directory.Read.All on the Gov Microsoft Graph (run while connected to AzureUSGovernment) so service-principal tags show display names instead of GUIDs — see Grant Graph API Permission.
CI/CD into Azure Government (Gov GitHub Actions OIDC federation,
portal.azure.usdeploy button) is not yet wired up — use the CLI flow above.
If you're contributing to Az-Stamper or want to deploy via GitHub Actions CI/CD instead of the one-click button, you'll need these tools:
| Tool | What It's For | Install |
|---|---|---|
| Azure CLI | Deploy infrastructure and manage Azure resources | Install |
| Bicep | Azure's infrastructure-as-code language (used by our templates) | az bicep install (included with Azure CLI) |
| Azure Functions Core Tools v4 | Build and deploy the function code | Install |
| .NET 8 SDK | Build the C# function project | Install |
| Azure PowerShell (Az module) | Create the Entra ID app registration and RBAC assignments | Install-Module Az -Scope CurrentUser |
You also need an Azure subscription where you have Owner or Contributor + User Access Administrator permissions.
GitHub Actions needs a way to authenticate to Azure to deploy code and infrastructure. Instead of storing passwords as secrets, we use OIDC federated credentials — GitHub proves its identity to Azure using a token, and Azure trusts it based on a pre-configured trust relationship. No secrets to rotate.
First time? Make sure you have the Az PowerShell module installed:
Install-Module Az -Scope CurrentUser
Connect-AzAccount
# Create the app registration (like a service account for deployments)
$app = New-AzADApplication -DisplayName "Az-Stamper-Deploy"
$sp = New-AzADServicePrincipal -ApplicationId $app.AppId
# Tell Azure to trust GitHub Actions for the "dev" environment
New-AzADAppFederatedCredential -ApplicationObjectId $app.Id `
-Name "github-actions-dev" `
-Issuer "https://token.actions.githubusercontent.com" `
-Subject "repo:<YOUR_ORG>/Az-Stamper:environment:dev" `
-Audience @("api://AzureADTokenExchange")
# Save these — you'll need them in Step 3
Write-Host "AZURE_CLIENT_ID: $($app.AppId)"
Write-Host "AZURE_TENANT_ID: $((Get-AzContext).Tenant.Id)"
Write-Host "AZURE_SUBSCRIPTION_ID: $((Get-AzContext).Subscription.Id)"
Write-Host "SP_OBJECT_ID: $($sp.Id)"Replace
<YOUR_ORG>with your GitHub organization or username.
The resource group is the container for all Az-Stamper resources. The deployment service principal needs Contributor (to create resources) and User Access Administrator (to assign roles to the function's managed identity).
$rgName = "rg-az-stamper-dev"
New-AzResourceGroup -Name $rgName -Location "eastus" -Force
# Let the deploy SP create resources and assign roles
New-AzRoleAssignment -ObjectId $sp.Id -RoleDefinitionName "Contributor" -ResourceGroupName $rgName
New-AzRoleAssignment -ObjectId $sp.Id -RoleDefinitionName "User Access Administrator" -ResourceGroupName $rgNameGitHub Environments let you scope secrets and variables to specific deployment targets (dev, prod, etc.) and add protection rules like required reviewers.
- In your GitHub repo, go to Settings → Environments → New environment → name it
dev - Add these secrets (the values from Step 1):
AZURE_CLIENT_IDAZURE_TENANT_IDAZURE_SUBSCRIPTION_ID
- Add these variables:
RESOURCE_GROUP=rg-az-stamper-devFUNCTION_APP_NAME=func-az-stamper-dev
This creates all Azure resources using the Bicep templates. The deployment takes about 2 minutes.
az deployment group create \
--resource-group rg-az-stamper-dev \
--template-file infra/main.bicep \
--parameters infra/parameters/dev.bicepparamThe command outputs three values you'll need for later steps. Save them:
functionAppName— the function app's namefunctionAppId— the function app's full Azure resource IDprincipalId— the managed identity's object ID
Azure Functions Core Tools handles the packaging and deployment correctly for the Consumption plan.
cd src/AzStamper.Functions
func azure functionapp publish func-az-stamper-dev --dotnet-isolatedYou should see ResourceStamper - [eventGridTrigger] in the output, confirming the function was detected.
This step connects the dots — it creates the Event Grid system topic that watches your subscription for resource events, and an event subscription that routes those events to your function. It also assigns the Reader and Tag Contributor roles to the function's managed identity at the subscription level.
This is a subscription-scoped deployment (not resource group-scoped), because Event Grid needs to watch the entire subscription.
az deployment sub create \
--location eastus \
--template-file infra/enroll.bicep \
--parameters \
functionAppName="func-az-stamper-dev" \
resourceGroupName="rg-az-stamper-dev"When a Service Principal or Managed Identity creates a resource, the event contains only a GUID (principal ID). To resolve this to a friendly name (e.g., "My-Deployment-Pipeline"), Az-Stamper needs permission to query Microsoft Graph.
This step requires Entra ID Global Administrator or Privileged Role Administrator permissions:
$miPrincipalId = (az functionapp identity show --name func-az-stamper-dev --resource-group rg-az-stamper-dev --query principalId -o tsv)
$graphSp = Get-AzADServicePrincipal -ApplicationId "00000003-0000-0000-c000-000000000000"
$role = $graphSp.AppRole | Where-Object { $_.Value -eq "Directory.Read.All" }
New-AzADServicePrincipalAppRoleAssignment `
-ServicePrincipalId $miPrincipalId `
-ResourceId $graphSp.Id `
-AppRoleId $role.IdIf you skip this step, Service Principal-created resources will be tagged with the raw GUID instead of a display name. Everything else works normally.
Create a test resource and check if tags appear:
# Create a test storage account
az storage account create \
--name stazstampertest \
--resource-group rg-az-stamper-dev \
--sku Standard_LRS
# Wait 60-90 seconds for the event to flow through
# (first invocation has a cold start delay)
# Check the tags
az tag list \
--resource-id /subscriptions/<SUB_ID>/resourceGroups/rg-az-stamper-dev/providers/Microsoft.Storage/storageAccounts/stazstampertestYou should see all five tags: Creator, CreatedOn, LastModifiedBy, LastModifiedOn, StampedBy.
# Clean up
az storage account delete --name stazstampertest --resource-group rg-az-stamper-dev --yesWhen a new version of Az-Stamper is released, click the Deploy Hub to Azure button again with the same parameters you used originally. The default packageUrl always points to the latest release, so the deployment will download and deploy the new version automatically. Bicep is idempotent — existing resources are left untouched, only the function code is updated.
Az-Stamper's function app uses a system-assigned managed identity — an automatically-managed service account that requires no passwords or key rotation. The Bicep templates assign these roles automatically:
| Role | Where | Why |
|---|---|---|
| Tag Contributor | Subscription | Read existing tags and write new ones on any resource |
| Reader | Subscription | Look up resource details and Service Principal information |
| Storage Blob Data Owner | Storage Account | Function runtime needs blob access for deployment packages |
| Storage Queue Data Contributor | Storage Account | Function runtime needs queue access for internal coordination |
| Storage Table Data Contributor | Storage Account | Function runtime needs table access for trigger state |
| Storage Account Contributor | Storage Account | Function runtime needs management access for file shares |
| Directory.Read.All (Graph API) | Entra ID tenant | Resolve Service Principal GUIDs to display names (optional) |
Az-Stamper supports monitoring multiple subscriptions from a single centralized function app using a hub-and-spoke model.
- Hub (one-time): Deploy the function app, storage, and monitoring to a resource group (existing
main.bicep) - Spoke (per-subscription): Enroll additional subscriptions with a Deploy-to-Azure button that creates Event Grid + RBAC
Subscriptions not explicitly configured receive the global default tags automatically.
Important: Switch to the subscription you want to enroll before clicking the button. The template deploys Event Grid resources into the target subscription and looks up the function app in the hub subscription.
Parameters:
| Parameter | Default | Description |
|---|---|---|
hubSubscriptionId |
(required) | Subscription ID where the Az-Stamper hub is deployed |
resourceGroupName |
rg-az-stamper |
Resource group containing the Az-Stamper hub |
functionAppName |
func-az-stamper |
Name of the Az-Stamper function app |
enrollmentResourceGroupName |
rg-az-stamper-enrollment |
Resource group created in this subscription for Event Grid resources |
The template cross-references the function app from the hub subscription and creates a lightweight resource group in the target subscription for the Event Grid system topic.
pwsh scripts/unenroll.ps1 \
-SubscriptionId "<subscription-id>" \
-FunctionAppPrincipalId "<principal-id>" \
-ResourceGroupName "<resource-group>"Use -WhatIf to preview changes without applying them.
Upload a stamper.json file to the config container in the hub storage account to customize tags per subscription:
{
"$schema": "https://raw.githubusercontent.com/Galvnyz/Az-Stamper/main/stamper.schema.json",
"subscriptions": {
"00000000-0000-0000-0000-000000000000": {
"displayName": "Production",
"enabled": true,
"tagOverrides": {
"Environment": { "value": "Production", "overwrite": false },
"CostCenter": { "value": "CC-1234", "overwrite": false }
},
"resourceTypeRules": {
"Microsoft.Compute/virtualMachines": {
"additionalTags": {
"ManagedBy": { "value": "InfraTeam", "overwrite": false }
}
}
}
}
}
}Subscriptions not listed receive global default tags. Set "enabled": false to pause tagging for a subscription without unenrolling.
Use these KQL queries in Application Insights → Logs:
// Active subscriptions (last 24h)
traces
| where timestamp > ago(24h)
| where message contains "Stamped"
| extend SubscriptionId = extract("/subscriptions/([^/]+)", 1, tostring(customDimensions.prop__ResourceId))
| where isnotempty(SubscriptionId)
| summarize EventCount=count() by SubscriptionId
| order by EventCount desc
// Tag success/failure rate (last 7d)
traces
| where timestamp > ago(7d)
| summarize
Tagged=countif(message contains "Stamped"),
Skipped=countif(message contains "skipping"),
Errors=countif(message contains "Failed")
by bin(timestamp, 1d)The repo includes GitHub Actions workflows and Dependabot configuration:
| Workflow | Trigger | What It Does |
|---|---|---|
| CI | Push to main, pull requests |
Builds the solution, runs 23 unit tests, checks code formatting, validates Bicep templates |
| Deploy | Manual trigger (workflow_dispatch) |
Deploys infrastructure and function code to the selected environment |
| Dependabot | Weekly (automatic) | Opens pull requests when NuGet packages or GitHub Actions have updates |
Auto-merge is enabled — Dependabot PRs merge automatically once CI passes, keeping dependencies current without manual intervention.
# Build
dotnet build Az-Stamper.sln
# Run tests (23 unit tests, no Azure credentials needed)
dotnet test Az-Stamper.sln
# Check code formatting
dotnet format Az-Stamper.sln --verify-no-changes
# Run locally (requires Azure Functions Core Tools + local.settings.json)
cd src/AzStamper.Functions
func startsrc/
AzStamper.Core/ Business logic (no Azure Functions dependency)
Models/ Configuration POCOs and event model
Services/ Tag operations and identity resolution (behind interfaces)
StampOrchestrator.cs Core flow: resolve caller → check ignore list → stamp tags
AzStamper.Functions/ Azure Functions entry point (thin adapter)
Functions/ Event Grid trigger function
Program.cs Dependency injection and configuration binding
tests/
AzStamper.Core.Tests/ Unit tests (xUnit + Moq, mocks all Azure SDK calls)
infra/
main.bicep Resource group deployment (storage, function, monitoring)
enroll.bicep Subscription enrollment (Event Grid, RBAC)
modules/ Individual Bicep modules
parameters/ Environment-specific parameter files
On a Consumption plan, Az-Stamper costs effectively $0/month for small to medium subscriptions. Azure provides a free grant of 1 million executions and 400,000 GB-seconds per month. A typical dev subscription generating a few dozen resource events per day won't come close to these limits. The only fixed cost is the storage account (~$0.02/month).
| Check | Command | What to look for |
|---|---|---|
| Function app running? | az functionapp show --name <func> --resource-group <rg> --query state |
Should return "Running". If 503, check WEBSITE_RUN_FROM_PACKAGE URL and storage RBAC. |
| Event Grid provider registered? | az provider show --namespace Microsoft.EventGrid --query registrationState |
Must be "Registered". If not: az provider register --namespace Microsoft.EventGrid |
| Event subscription exists? | az eventgrid system-topic event-subscription list --system-topic-name evgt-az-stamper --resource-group <rg> |
Should show evgs-az-stamper with provisioningState: Succeeded |
| Events being delivered? | Check Event Grid metrics in the portal: System Topic → Metrics → Delivery Success/Fail Count | If delivery fails show "Busy", the function is cold-starting. Wait 2-3 minutes and retry. |
| Function receiving events? | Application Insights → Logs → traces | where message contains "Stamped" | order by timestamp desc |
Should show recent stamp operations. If empty, events aren't reaching the function. |
| Resource type ignored? | Check StamperConfig__IgnorePatterns__* app settings |
The resource's provider path may match an ignore pattern. |
The Consumption plan scales to zero when idle. A 503 can mean:
- Cold start — first request after idle takes 10-30 seconds. Event Grid retries automatically; tags appear after 1-2 minutes.
- Bad deployment package — verify
WEBSITE_RUN_FROM_PACKAGEURL is accessible:curl -s -o /dev/null -w "%{http_code}" "<url>". Should return 200. Check that the URL has a?before the SAS token parameters. - Missing storage RBAC — the function's managed identity needs
Storage Blob Data Owner,Storage Queue Data Contributor, andStorage Table Data Contributoron the storage account. Check with:az role assignment list --assignee <principalId> --all
If the system topic exists but events aren't flowing:
- Verify the event subscription endpoint type is
AzureFunction(notWebHook):az eventgrid system-topic event-subscription show --name evgs-az-stamper --system-topic-name evgt-az-stamper --resource-group <rg> --query destination.endpointType - Check the
resourceIdpoints to the correct function:...Microsoft.Web/sites/<func>/functions/ResourceStamper - If the event subscription is broken, redeploy via Bicep — don't manually delete and recreate:
az deployment sub create --location <region> --template-file infra/enroll.bicep \ --parameters functionAppName='<func>' resourceGroupName='<rg>'
- Verify the SWA is deployed:
az staticwebapp show --name <func>-config --resource-group <rg> - Check that
app-config.jswas generated (not the.samplefile): look inswa/js/app-config.js - Re-run the setup script:
pwsh -File scripts/Setup-SwaAuth.ps1
// Recent stamp operations
traces
| where timestamp > ago(1h)
| where message contains "Stamped" or message contains "skipping" or message contains "Failed"
| project timestamp, message
| order by timestamp desc
// Function invocation success/failure
requests
| where timestamp > ago(24h)
| where name == "ResourceStamper"
| summarize Succeeded=countif(success), Failed=countif(not(success)) by bin(timestamp, 1h)MIT — Inspired by original work by Anthony Watherston.