Integrations — IIS Log Export
IIS Log Export
This page documents how to extract LLM bot traffic from IIS access logs and ship it to Maya, with all data minimization rules applied at source.
When to use this guide: your production stack is IIS-hosted (with or without SharePoint), with origin logs available on the Windows host or a log share. If you have a BFF or API gateway in front of IIS, see
bff-endpoint.mdinstead — it's typically a cleaner integration.
Prerequisites
| Requirement | Notes |
|---|---|
| IIS 8.0 or newer | Required for W3C log format |
| W3C extended logging enabled | With at minimum: date, time, cs-method, cs-uri-stem, cs-uri-query, sc-status, sc-bytes, cs-User-Agent, cs(Referer), cs-host |
| PowerShell 5.1+ | For the filter script. PowerShell 7+ also supported. |
| LogParser 2.2 (optional) | Recommended for very large log files; falls back to native PowerShell otherwise. |
Outbound HTTPS to ingest.withmaya.ai on port 443 | Required for transmission |
| Maya API key for the tenant | Provided by Maya; stored as a Windows Credential or environment variable, never in source. |
IIS log fields recommended
In IIS Manager → Logging → Select Fields, ensure these are enabled:
date ✅
time ✅
s-ip ⚠ ignored by filter
cs-method ✅
cs-uri-stem ✅
cs-uri-query ✅ (will be sanitized)
s-port ⚠ ignored
cs-username ❌ disable if enabled (we never want this)
c-ip ⚠ hashed by filter, never raw
cs(User-Agent) ✅
cs(Referer) ✅
cs-host ✅
sc-status ✅
sc-bytes ✅
time-taken ⚠ optional, useful for performance, not transmittedFilter script
Save as Export-LLMBotLogs.ps1 on the IIS host (or on a log-aggregation host with read access to the log share).
<#
Maya — IIS LLM Bot Log Filter
Reads W3C IIS logs, retains only verified LLM bot traffic, applies PII strip,
produces NDJSON output.
Usage:
.\Export-LLMBotLogs.ps1 -LogPath 'C:\inetpub\logs\LogFiles\W3SVC1' `
-OutputPath 'D:\maya\export-2026-04-22.ndjson' `
-Since '2026-04-21T00:00:00Z' `
-TenantSecret (Get-MayaSecret)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)] [string]$LogPath,
[Parameter(Mandatory=$true)] [string]$OutputPath,
[Parameter(Mandatory=$true)] [datetime]$Since,
[Parameter(Mandatory=$true)] [string]$TenantSecret,
[switch]$DryRun
)
# 1. Verified LLM bot allowlist (case-insensitive substring match).
$BotPatterns = @(
'GPTBot','ChatGPT-User','OAI-SearchBot',
'ClaudeBot','Claude-Web','anthropic-ai',
'PerplexityBot','Perplexity-User',
'Google-Extended','Googlebot',
'Bingbot',
'Applebot-Extended',
'Bytespider','CCBot',
'Meta-ExternalAgent','FacebookBot',
'DuckAssistBot','YouBot','Amazonbot','Diffbot'
)
# 2. Query-string keys to strip.
$DenyKeys = @('email','tckn','phone','customer_id','account_id',
'session','token','key','secret','auth','csrf')
# 3. HMAC-SHA256 IP hasher.
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [Text.Encoding]::UTF8.GetBytes($TenantSecret)
function Hash-IP([string]$ip) {
if ([string]::IsNullOrWhiteSpace($ip)) { return $null }
$bytes = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($ip))
return [BitConverter]::ToString($bytes).Replace('-','').ToLower().Substring(0,32)
}
# 4. Sanitize query string.
function Sanitize-Query([string]$q) {
if ([string]::IsNullOrWhiteSpace($q)) { return '' }
$pairs = $q -split '&' | Where-Object { $_ -ne '' } | ForEach-Object {
$kv = $_ -split '=', 2
$k = $kv[0].ToLower()
if ($DenyKeys -contains $k) { return $null }
return $_
} | Where-Object { $_ -ne $null }
return ($pairs -join '&')
}
# 5. Discover log files modified since cutoff.
$logFiles = Get-ChildItem -Path $LogPath -Filter '*.log' -File |
Where-Object { $_.LastWriteTimeUtc -ge $Since.ToUniversalTime() }
# 6. Iterate and emit NDJSON.
$writer = New-Object System.IO.StreamWriter($OutputPath, $false, [Text.Encoding]::UTF8)
$total = 0; $kept = 0
try {
foreach ($file in $logFiles) {
# Header detection — IIS W3C format.
$headerLine = (Get-Content -Path $file.FullName -TotalCount 4 |
Where-Object { $_ -like '#Fields:*' }) -replace '#Fields:\s*',''
if (-not $headerLine) { continue }
$headers = $headerLine -split '\s+'
Get-Content -Path $file.FullName | ForEach-Object {
if ($_.StartsWith('#') -or [string]::IsNullOrWhiteSpace($_)) { return }
$total++
$values = $_ -split '\s+'
if ($values.Length -ne $headers.Length) { return }
$row = @{}
for ($i = 0; $i -lt $headers.Length; $i++) {
$row[$headers[$i]] = $values[$i]
}
$ua = [string]$row['cs(User-Agent)']
$matched = $false
foreach ($p in $BotPatterns) {
if ($ua -match [regex]::Escape($p)) { $matched = $true; break }
}
if (-not $matched) { return }
$stamp = "$($row['date'])T$($row['time'])Z"
$stem = [string]$row['cs-uri-stem']
$query = Sanitize-Query ([string]$row['cs-uri-query'])
$path = if ($query) { "$stem`?$query" } else { $stem }
$record = [ordered]@{
timestamp = $stamp
user_agent = $ua
request_method = [string]$row['cs-method']
request_path = $path
status_code = [int]([string]$row['sc-status'])
response_bytes = [int]([string]$row['sc-bytes'])
referrer = [string]$row['cs(Referer)']
host = [string]$row['cs-host']
client_ip_hash = Hash-IP ([string]$row['c-ip'])
}
$kept++
if (-not $DryRun) {
$writer.WriteLine( ($record | ConvertTo-Json -Compress) )
}
}
}
}
finally {
$writer.Flush(); $writer.Close()
Write-Host "Scanned $total rows, retained $kept rows."
if ($DryRun) { Write-Host 'Dry run — no file written.' }
}Notes on the script
- The script is idempotent: it reads files modified since
-Since. Re-running with the same cutoff produces the same output (modulo log rotation). - The script never writes raw IPs. The HMAC step is keyed by a tenant secret held by the brand. Maya does not know this secret. If the secret is rotated, hashes from before and after rotation will not collide.
- The
-DryRunflag computes counts without producing output. Use this for source-side audit.
Expected output (sample)
The script produces newline-delimited JSON (.ndjson). Each line is one bot request, one record. Here are the first lines of a real (anonymized) export, exactly what your file should look like:
{"timestamp":"2026-04-22T03:14:07Z","user_agent":"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)","request_method":"GET","request_path":"/products/super-widget","status_code":200,"response_bytes":48329,"referrer":"","host":"www.example.com","client_ip_hash":"a4c6f1b2e7d8c1bb"}
{"timestamp":"2026-04-22T03:14:42Z","user_agent":"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +https://www.anthropic.com/claudebot)","request_method":"GET","request_path":"/pricing","status_code":200,"response_bytes":62104,"referrer":"","host":"www.example.com","client_ip_hash":"7d9e22aa1f53e2c4"}
{"timestamp":"2026-04-22T03:15:11Z","user_agent":"Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)","request_method":"GET","request_path":"/products/super-widget","status_code":200,"response_bytes":48329,"referrer":"https://www.perplexity.ai/","host":"www.example.com","client_ip_hash":"3f8b22cc09111ab7"}Download a full sample (15 rows, ~5 KB):
maya-bot-logs.sample.ndjson
Sanity checks on your output
Run these before sending the file to Maya:
# 1) Each line is valid JSON
Get-Content export.ndjson | ForEach-Object { try { $null = $_ | ConvertFrom-Json } catch { Write-Host "Invalid: $_" } }
# 2) No disallowed fields appear (cookie, authorization, set-cookie, session, token)
Get-Content export.ndjson | Select-String -Pattern "(?i)(cookie|authorization|set-cookie|session|token|tckn|customer_id|account_id)"
# Expected output: nothing.
# 3) Every user_agent matches an LLM bot
Get-Content export.ndjson | ForEach-Object {
$r = $_ | ConvertFrom-Json
if ($r.user_agent -notmatch '(?i)GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-Web|anthropic-ai|PerplexityBot|Perplexity-User|Google-Extended|Googlebot|Bingbot|Applebot-Extended|Bytespider|CCBot|Meta-ExternalAgent|FacebookBot|DuckAssistBot|YouBot|Amazonbot|Diffbot') {
Write-Host "Non-bot UA leaked: $($r.user_agent)"
}
}
# Expected output: nothing.If all three checks emit nothing, the export is safe to transmit.
Transmission
After the NDJSON file is produced, post it to Maya's ingestion endpoint:
$apiKey = $env:MAYA_API_KEY
$url = 'https://ingest.withmaya.ai/v1/logs'
$response = Invoke-WebRequest -Uri $url `
-Method Post `
-InFile 'D:\maya\export-2026-04-22.ndjson' `
-ContentType 'application/x-ndjson' `
-Headers @{
'Authorization' = "Bearer $apiKey"
'X-Maya-Tenant' = 'acme-prod'
'X-Maya-Schema' = '1'
}
if ($response.StatusCode -ne 202) {
throw "Maya ingestion returned $($response.StatusCode)"
}The endpoint returns 202 Accepted on success, with a body containing the batch ID for traceability.
Scheduling
Two recommended schedules:
- Daily (most clients): run at off-peak; ship the prior day's logs.
- Weekly (privacy-conservative clients): run on Sunday for the prior week.
Schedule via Task Scheduler:
schtasks /Create /TN "Maya LLM Log Export" /SC DAILY /ST 03:00 ^
/TR "powershell.exe -ExecutionPolicy Bypass -File D:\maya\Export-LLMBotLogs.ps1 ..."Validation checklist
Before going to production with this filter:
- Run
Export-LLMBotLogs.ps1 -DryRunon 24 hours of logs. Confirm output row count is reasonable (typically 0.1% – 5% of total rows). - Inspect 100 random rows of dry-run output. Confirm:
- No raw IPs.
- No cookies, no auth headers, no session tokens.
- No PII in
request_path. - All
user_agentvalues match the allowlist.
- Run with output to a non-production file. Open in a text editor; eyeball for surprises.
- POST a sample batch to Maya's
/v1/logsendpoint withX-Maya-Mode: testto verify the schema accepts it without ingesting. - Sign off the script in your change management system.
- Schedule and enable.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Output file is empty | No bot traffic in window, or cs(User-Agent) field disabled in IIS | Confirm IIS field selection; widen -Since. |
400 Bad Request from Maya | Schema mismatch (extra or missing field) | Inspect output NDJSON; remove non-allowlist fields. |
401 Unauthorized | API key missing/invalid | Re-check Windows Credential / env var. |
429 Too Many Requests | Burst over rate limit | Implement exponential backoff or split batch. |
Rows have weird \xc2\xae-style encoding | UTF-8 BOM mismatch | Force [Text.Encoding]::UTF8 (no BOM); verify in script. |
Migration note (BFF topology)
If you later move logging to a BFF or API gateway layer, this script can be retired in favor of the BFF endpoint pattern documented in bff-endpoint.md. The schema is identical, so dashboards and downstream analysis are unaffected.