Files
ngit-grasp/docs/how-to/migration-scripts/30-extract-parse-failures.sh
T
DanConwayDev ef50f4bd97 Fix parse failure output to show repo|npub instead of event_id|kind
Phase 4 (30-extract-parse-failures.sh) now enriches parse failures with
repo name and npub by looking up event_id in announcements.json. This is
critical because 'Invalid announcement' rejections only log event_id and
kind, not the repo name or npub.

Phase 5 (40-classify-actions.sh) was also fixed to extract columns 4 and 5
(repo|npub) instead of columns 1 and 2 (event_id|kind) from parse-failures.txt.

Without this fix, action-required.txt showed unusable output like:
  000014b2... | 30617 | parse failure logged | fix event format...

Now it correctly shows:
  scripts | npub1hs5244... | parse failure logged | fix event format...

The enrichment uses jq to build a lookup table from announcements.json and
optionally uses 'nak' to convert hex pubkeys to npub format.
2026-01-27 20:38:09 +00:00

840 lines
33 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# 30-extract-parse-failures.sh - Extract parse failure events from systemd logs
#
# PHASE 4a of the GRASP relay to ngit-grasp migration analysis pipeline.
# Extracts structured [PARSE_FAIL] log entries AND "Invalid announcement"
# rejections from journalctl.
#
# USAGE:
# ./30-extract-parse-failures.sh <service-name> <output-dir> [options]
#
# EXAMPLES:
# # Extract from ngit-grasp service (last 30 days, default)
# ./30-extract-parse-failures.sh ngit-grasp.service output/logs
#
# # Extract with custom time range
# ./30-extract-parse-failures.sh ngit-grasp.service output/logs --since "2026-01-01"
#
# # Extract from specific time window
# ./30-extract-parse-failures.sh ngit-grasp.service output/logs --since "2026-01-15" --until "2026-01-22"
#
# OPTIONS:
# --since <date> Start date for log extraction (default: 30 days ago)
# --until <date> End date for log extraction (default: now)
# --dry-run Show what would be extracted without writing files
#
# OUTPUT:
# <output-dir>/parse-failures.txt
#
# OUTPUT FORMAT (TSV):
# event_id<TAB>kind<TAB>reason<TAB>repo<TAB>npub
#
# EXPECTED LOG FORMATS:
# The script looks for two types of log entries:
#
# 1. Structured [PARSE_FAIL] entries:
# 2026-01-22T10:30:45Z ngit-grasp[1234]: [PARSE_FAIL] kind=30618 event_id=abc123... reason="invalid refs format" repo=myrepo npub=npub1...
#
# 2. "Invalid announcement" rejections (write policy):
# Event rejected by write policy event_id=abc123... relay=wss://... kind=30617 reason=Invalid announcement: multiple clone tags found...
#
# NOTE: Builder logs ("Rejected repository announcement note1xxx:") are NOT extracted
# because they use bech32 (note1) IDs while write policy logs use hex IDs. Extracting
# both would cause double-counting since deduplication only works within each format.
# Write policy logs contain the same events, so we don't lose any data.
#
# Required fields: kind, event_id, reason
# Optional fields: repo, npub (may not be available for all entry types)
#
# DEPENDENCY:
# This script requires logging improvements in ngit-grasp to emit structured
# [PARSE_FAIL] log entries. Until those are implemented, this script will
# find no matching entries (which is handled gracefully).
#
# "Invalid announcement" rejections are logged by the write policy and
# should be present in any ngit-grasp deployment.
#
# See: docs/how-to/migrate-to-ngit-grasp.md (Dependencies section)
#
# Expected Rust logging code for [PARSE_FAIL]:
# tracing::warn!(
# target: "migration",
# "[PARSE_FAIL] kind={} event_id={} reason=\"{}\" repo={} npub={}",
# event.kind, event.id, reason, identifier, npub
# );
#
# PREREQUISITES:
# - journalctl (systemd)
# - grep, awk, sed (standard Unix tools)
# - Access to systemd journal (may require sudo or journal group membership)
#
# RUNTIME: Depends on log volume, typically < 30 seconds
#
# SEE ALSO:
# docs/how-to/migrate-to-ngit-grasp.md - Full migration guide
# 31-extract-purgatory-expiry.sh - Companion script for purgatory expiry logs
#
set -euo pipefail
# Get script directory for sourcing helpers
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source the service validation helper
if [[ -f "$SCRIPT_DIR/validate-service.sh" ]]; then
source "$SCRIPT_DIR/validate-service.sh"
fi
# Colors for output (disabled if not a terminal)
if [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'
else
RED=''
GREEN=''
YELLOW=''
BLUE=''
NC=''
fi
log_info() {
echo -e "${BLUE}[INFO]${NC} $*" >&2
}
log_success() {
echo -e "${GREEN}[OK]${NC} $*" >&2
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $*" >&2
}
log_error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
}
usage() {
echo "Usage: $0 <service-name> <output-dir> [options]"
echo ""
echo "Arguments:"
echo " service-name Systemd service name (e.g., ngit-grasp.service)"
echo " output-dir Directory to store extracted log data"
echo ""
echo "Options:"
echo " --since <date> Start date (default: 30 days ago)"
echo " --until <date> End date (default: now)"
echo " --analysis-root <dir> Filter to only missing announcements from analysis"
echo " --dry-run Show what would be extracted without writing"
echo ""
echo "Examples:"
echo " $0 ngit-grasp.service output/logs"
echo " $0 ngit-grasp.service output/logs --since '2026-01-01'"
echo " $0 ngit-grasp.service output/logs --since '2026-01-15' --until '2026-01-22'"
echo " $0 ngit-grasp.service output/logs --analysis-root /tmp/migration-analysis-20260123"
echo ""
echo "Expected log formats:"
echo " [PARSE_FAIL] kind=30618 event_id=abc123 reason=\"...\" repo=myrepo npub=npub1..."
echo " Event rejected by write policy event_id=abc123 ... kind=30617 reason=Invalid announcement: ..."
echo ""
echo "Filtering with --analysis-root:"
echo " When provided, only parse failures for announcements that are in production"
echo " but missing from the archive will be included. This filters out rejections"
echo " for events from other relays that don't affect the migration."
exit 1
}
# Parse a [PARSE_FAIL] log line and extract fields
# Input: log line containing [PARSE_FAIL]
# Output: TSV line: event_id<TAB>kind<TAB>reason<TAB>repo<TAB>npub
parse_parse_fail_line() {
local line="$1"
# Extract fields using grep -oP (Perl regex) or awk
# Fields: kind, event_id, reason, repo (optional), npub (optional)
local kind event_id reason repo npub
# Extract kind=VALUE
kind=$(echo "$line" | grep -oP 'kind=\K[0-9]+' || echo "")
# Extract event_id=VALUE (hex string, possibly truncated with ...)
event_id=$(echo "$line" | grep -oP 'event_id=\K[a-f0-9]+' || echo "")
# Extract reason="VALUE" (quoted string)
reason=$(echo "$line" | grep -oP 'reason="\K[^"]*' || echo "")
# Extract repo=VALUE (optional, unquoted identifier)
repo=$(echo "$line" | grep -oP 'repo=\K[^ ]+' || echo "")
# Extract npub=VALUE (optional, npub1... format)
npub=$(echo "$line" | grep -oP 'npub=\K[^ ]+' || echo "")
# Only output if we have the required fields
if [[ -n "$kind" && -n "$event_id" && -n "$reason" ]]; then
printf '%s\t%s\t%s\t%s\t%s\n' "$event_id" "$kind" "$reason" "$repo" "$npub"
fi
}
# Parse an "Invalid announcement" rejection log line from write policy
# Input: log line containing "Event rejected by write policy" with "Invalid announcement"
# Output: TSV line: event_id<TAB>kind<TAB>reason<TAB>repo<TAB>npub
# Note: repo and npub are empty for these entries (not available in log format)
parse_write_policy_rejection_line() {
local line="$1"
local kind event_id reason
# Extract event_id=VALUE (hex string)
event_id=$(echo "$line" | grep -oP 'event_id=\K[a-f0-9]+' || echo "")
# Extract kind=VALUE
kind=$(echo "$line" | grep -oP 'kind=\K[0-9]+' || echo "")
# Extract reason=VALUE (everything after "reason=")
# The reason is unquoted and goes to end of line
reason=$(echo "$line" | grep -oP 'reason=\K.*$' || echo "")
# Only output if we have the required fields
if [[ -n "$kind" && -n "$event_id" && -n "$reason" ]]; then
# repo and npub are empty for invalid announcement entries
printf '%s\t%s\t%s\t\t\n' "$event_id" "$kind" "$reason"
fi
}
# NOTE: parse_builder_rejection_line() was removed to fix double-counting bug.
# Builder logs use bech32 (note1) IDs while write policy logs use hex IDs.
# Since deduplication only works within each format, extracting both caused
# the same event to be counted twice. Write policy logs contain the same
# events, so we don't lose any data by only extracting from that source.
# Enrich parse failures with repo/npub by looking up event_id in announcements.json
# This is critical because "Invalid announcement" rejections only log event_id and kind,
# not the repo name or npub. Without enrichment, Phase 5 shows event_id|kind instead
# of repo|npub in action-required.txt, making the output unusable.
#
# Arguments:
# $1 - parse failures file to enrich (modified in place)
# $2 - analysis root directory containing prod/raw/announcements.json
#
# The function:
# 1. Builds a lookup table from announcements.json: event_id -> repo|npub
# 2. For each parse failure with empty repo/npub, looks up the event_id
# 3. Populates repo and npub columns from the lookup
enrich_with_repo_npub() {
local parse_failures_file="$1"
local analysis_root="$2"
local prod_announcements="$analysis_root/prod/raw/announcements.json"
# Validate required file exists
if [[ ! -f "$prod_announcements" ]]; then
log_warn "Production announcements file not found: $prod_announcements"
log_warn "Skipping enrichment - repo/npub columns will remain empty"
return 0
fi
# Check if jq is available
if ! command -v jq &> /dev/null; then
log_warn "jq not found - cannot enrich parse failures with repo/npub"
log_warn "Install jq or run without --analysis-root"
return 0
fi
log_info "Enriching parse failures with repo/npub from announcements..."
# Step 1: Build lookup table from announcements.json
# Output format: event_id<TAB>repo<TAB>npub
local lookup_file
lookup_file=$(mktemp)
# Extract id, d-tag (repo identifier), and pubkey from announcements
# Convert pubkey to npub using bech32 encoding
# Note: We use a simple hex-to-npub conversion via external tool if available,
# otherwise we'll use the hex pubkey (Phase 5 can still match on it)
log_info " Building event_id -> repo/npub lookup table..."
# First, extract the raw data: id, d-tag, pubkey (hex)
jq -r 'select(.kind == 30617) |
.id as $id |
.pubkey as $pubkey |
((.tags[] | select(.[0] == "d") | .[1]) // "") as $dtag |
"\($id)\t\($dtag)\t\($pubkey)"' "$prod_announcements" > "$lookup_file.raw" 2>/dev/null || {
log_warn "Failed to parse production announcements JSON"
rm -f "$lookup_file" "$lookup_file.raw"
return 0
}
# Convert hex pubkeys to npub format
# Check if we have a tool to do bech32 encoding (nak, nostr-tool, etc.)
local can_convert_npub=false
if command -v nak &> /dev/null; then
can_convert_npub=true
log_info " Using 'nak' for pubkey->npub conversion"
fi
# Process the lookup file, converting pubkeys to npubs if possible
while IFS=$'\t' read -r event_id repo pubkey_hex; do
local npub
if [[ "$can_convert_npub" == true && -n "$pubkey_hex" ]]; then
# Use nak to encode pubkey as npub
npub=$(nak encode npub "$pubkey_hex" 2>/dev/null || echo "")
fi
# Fall back to hex pubkey if conversion failed
[[ -z "$npub" ]] && npub="$pubkey_hex"
printf '%s\t%s\t%s\n' "$event_id" "$repo" "$npub"
done < "$lookup_file.raw" > "$lookup_file"
rm -f "$lookup_file.raw"
local lookup_count
lookup_count=$(wc -l < "$lookup_file")
lookup_count="${lookup_count//[^0-9]/}"
log_info " Built lookup table with $lookup_count announcements"
# Step 2: Enrich parse failures
local enriched_file
enriched_file=$(mktemp)
# Copy header lines
grep '^#' "$parse_failures_file" > "$enriched_file"
# Process data lines
local enriched_count=0
local total_count=0
while IFS=$'\t' read -r event_id kind reason repo npub; do
# Skip header lines (already copied)
[[ "$event_id" =~ ^# ]] && continue
total_count=$((total_count + 1))
# If repo and npub are already populated, keep them
if [[ -n "$repo" && -n "$npub" ]]; then
printf '%s\t%s\t%s\t%s\t%s\n' "$event_id" "$kind" "$reason" "$repo" "$npub" >> "$enriched_file"
continue
fi
# Look up event_id in our table
local lookup_result
lookup_result=$(grep "^${event_id}"$'\t' "$lookup_file" 2>/dev/null | head -1 || echo "")
if [[ -n "$lookup_result" ]]; then
local looked_up_repo looked_up_npub
looked_up_repo=$(echo "$lookup_result" | cut -f2)
looked_up_npub=$(echo "$lookup_result" | cut -f3)
# Use looked-up values if original was empty
[[ -z "$repo" ]] && repo="$looked_up_repo"
[[ -z "$npub" ]] && npub="$looked_up_npub"
enriched_count=$((enriched_count + 1))
fi
printf '%s\t%s\t%s\t%s\t%s\n' "$event_id" "$kind" "$reason" "$repo" "$npub" >> "$enriched_file"
done < "$parse_failures_file"
# Replace original with enriched version
mv "$enriched_file" "$parse_failures_file"
# Cleanup
rm -f "$lookup_file"
log_info " Enriched $enriched_count of $total_count parse failures with repo/npub"
log_success "Enrichment complete"
}
# Filter parse failures to only those for missing announcements
# This is used when --analysis-root is provided to scope results to the migration
#
# Arguments:
# $1 - parse failures file to filter (modified in place)
# $2 - analysis root directory containing comparison/ and prod/ subdirs
#
# The function:
# 1. Reads missing announcements from comparison/complete-prod-missing-archive.txt
# 2. Extracts pubkey/identifier pairs for those announcements
# 3. Reads production announcements from prod/raw/announcements.json
# 4. Gets event IDs for announcements matching the missing pubkey/identifier pairs
# 5. Filters parse failures to only those event IDs
filter_to_missing_announcements() {
local parse_failures_file="$1"
local analysis_root="$2"
local missing_file="$analysis_root/comparison/complete-prod-missing-archive.txt"
local prod_announcements="$analysis_root/prod/raw/announcements.json"
# Validate required files exist
if [[ ! -f "$missing_file" ]]; then
log_warn "Missing announcements file not found: $missing_file"
log_warn "Skipping filter - all parse failures will be included"
return 0
fi
if [[ ! -f "$prod_announcements" ]]; then
log_warn "Production announcements file not found: $prod_announcements"
log_warn "Skipping filter - all parse failures will be included"
return 0
fi
# Check if jq is available
if ! command -v jq &> /dev/null; then
log_warn "jq not found - cannot filter parse failures"
log_warn "Install jq or run without --analysis-root"
return 0
fi
log_info "Filtering parse failures to missing announcements only..."
# Step 1: Extract pubkey/identifier pairs from missing announcements
# Format: identifier | npub | prod=complete | archive=missing
local missing_pairs_file
missing_pairs_file=$(mktemp)
# Extract identifier and npub, convert npub to hex pubkey for matching
while IFS=' | ' read -r identifier npub rest; do
# Skip empty lines
[[ -z "$identifier" ]] && continue
# Trim whitespace
identifier=$(echo "$identifier" | xargs)
npub=$(echo "$npub" | xargs)
echo "${identifier}|${npub}"
done < "$missing_file" > "$missing_pairs_file"
local missing_count
missing_count=$(wc -l < "$missing_pairs_file")
missing_count="${missing_count//[^0-9]/}"
log_info " Found $missing_count missing announcements to filter for"
# Step 2: Get event IDs from production announcements for these pairs
# We need to match on 'd' tag (identifier) and pubkey
local missing_event_ids_file
missing_event_ids_file=$(mktemp)
# Create a lookup of identifier|npub -> event_id from production announcements
# The JSON has: id, pubkey (hex), tags (array with ["d", identifier])
log_info " Extracting event IDs from production announcements..."
# Use jq to extract id, pubkey, and d-tag value, then filter
# Output format: event_id|identifier|pubkey_hex
# Note: The JSON file is NDJSON (newline-delimited), not an array
jq -r 'select(.kind == 30617) |
.id as $id |
.pubkey as $pubkey |
(.tags[] | select(.[0] == "d") | .[1]) as $dtag |
"\($id)|\($dtag)|\($pubkey)"' "$prod_announcements" > "$missing_event_ids_file.all" 2>/dev/null || {
log_warn "Failed to parse production announcements JSON"
rm -f "$missing_pairs_file" "$missing_event_ids_file" "$missing_event_ids_file.all"
return 0
}
# Now filter to only event IDs for missing announcements
# We need to convert npub to hex pubkey for comparison
# npub is bech32, pubkey in JSON is hex
# For simplicity, we'll match on identifier only (d-tag) since it should be unique per pubkey
# Actually, we need both because same identifier can exist for different pubkeys
# Create a set of "identifier|pubkey_hex" to match against
# First, we need to convert npub to hex - but that requires a tool
# Alternative: match on identifier only and accept some false positives
# Better: use the comparison file which has npub, and match against announcements
# Let's match on identifier only for now (simpler, may have minor false positives)
# Extract just the identifiers from missing announcements
local missing_identifiers_file
missing_identifiers_file=$(mktemp)
cut -d'|' -f1 "$missing_pairs_file" | sort -u > "$missing_identifiers_file"
# Filter event IDs to only those with matching identifiers
while IFS='|' read -r event_id identifier pubkey_hex; do
if grep -qFx "$identifier" "$missing_identifiers_file"; then
echo "$event_id"
fi
done < "$missing_event_ids_file.all" | sort -u > "$missing_event_ids_file"
local event_id_count
event_id_count=$(wc -l < "$missing_event_ids_file")
event_id_count="${event_id_count//[^0-9]/}"
log_info " Found $event_id_count event IDs for missing announcements"
# Step 3: Filter parse failures to only those event IDs
local filtered_file
filtered_file=$(mktemp)
# Copy header lines
grep '^#' "$parse_failures_file" > "$filtered_file"
# Add a note about filtering
echo "# Filtered to missing announcements only (--analysis-root)" >> "$filtered_file"
echo "# Analysis root: $analysis_root" >> "$filtered_file"
echo "# Missing announcements: $missing_count" >> "$filtered_file"
echo "# Matching event IDs: $event_id_count" >> "$filtered_file"
# Filter data lines - only include if event_id is in our list
local filtered_count=0
while IFS=$'\t' read -r event_id kind reason repo npub; do
# Skip header lines (already copied)
[[ "$event_id" =~ ^# ]] && continue
# Check if this event_id is in our missing list
if grep -qFx "$event_id" "$missing_event_ids_file"; then
printf '%s\t%s\t%s\t%s\t%s\n' "$event_id" "$kind" "$reason" "$repo" "$npub" >> "$filtered_file"
filtered_count=$((filtered_count + 1))
fi
done < "$parse_failures_file"
# Replace original with filtered version
mv "$filtered_file" "$parse_failures_file"
# Cleanup temp files
rm -f "$missing_pairs_file" "$missing_event_ids_file" "$missing_event_ids_file.all" "$missing_identifiers_file"
log_info " Filtered from $(grep -v '^#' "$parse_failures_file" | wc -l | xargs) to $filtered_count parse failures"
log_success "Filtered to parse failures for missing announcements only"
}
# Main
main() {
if [[ $# -lt 2 ]]; then
usage
fi
local service="$1"
local output_dir="$2"
shift 2
# Default time range: last 30 days
local since_date
since_date=$(date -d "30 days ago" "+%Y-%m-%d" 2>/dev/null || date -v-30d "+%Y-%m-%d" 2>/dev/null || echo "")
local until_date=""
local dry_run=false
local analysis_root=""
# Parse options
while [[ $# -gt 0 ]]; do
case "$1" in
--since)
since_date="$2"
shift 2
;;
--until)
until_date="$2"
shift 2
;;
--analysis-root)
analysis_root="$2"
shift 2
;;
--dry-run)
dry_run=true
shift
;;
*)
log_error "Unknown option: $1"
usage
;;
esac
done
# Validate service name format
if [[ ! "$service" =~ \.service$ ]]; then
service="${service}.service"
fi
# Validate service is appropriate for structured logging
# This prevents the common mistake of using ngit-relay instead of ngit-grasp
if type validate_service_for_structured_logging &>/dev/null; then
# Use non-interactive mode if not a terminal, skip log check (we'll do our own)
local interactive="true"
[[ ! -t 0 ]] && interactive="false"
if ! validate_service_for_structured_logging "$service" "false" "$interactive"; then
log_error "Service validation failed. Use an ngit-grasp service for structured logging."
exit 1
fi
else
# Fallback validation if helper not available
if [[ "$service" == *"ngit-relay"* ]]; then
log_error "Service name appears to be ngit-relay: $service"
log_error "Structured logging ([PARSE_FAIL]) only exists in ngit-grasp services."
log_error "Please use the ngit-grasp archive service instead."
log_error ""
log_error "To find the correct service:"
log_error " systemctl list-units 'ngit-grasp*' --all"
exit 1
fi
fi
log_info "Extracting parse failures from systemd logs"
log_info "Service: $service"
log_info "Output: $output_dir"
log_info "Time range: ${since_date:-beginning} to ${until_date:-now}"
# Check if journalctl is available
if ! command -v journalctl &> /dev/null; then
log_error "journalctl not found. This script requires systemd."
exit 1
fi
# Validate service exists (check if journalctl can find any logs for it)
# Note: We don't require the service to be running, just that it has logs
if ! journalctl --no-pager -u "$service" -n 1 &>/dev/null; then
log_warn "Could not query logs for service: $service"
log_warn "This may indicate the service doesn't exist or you lack permissions."
log_warn ""
log_warn "To list available ngit-grasp services:"
log_warn " systemctl list-units 'ngit-grasp*' --all"
log_warn " journalctl --list-boots # Check if you have journal access"
log_warn ""
# Continue anyway - the service might exist but have no logs yet
fi
# Build journalctl command
local journal_cmd="journalctl -u $service --no-pager -o short-iso"
if [[ -n "$since_date" ]]; then
journal_cmd="$journal_cmd --since '$since_date'"
fi
if [[ -n "$until_date" ]]; then
journal_cmd="$journal_cmd --until '$until_date'"
fi
log_info "Running: $journal_cmd | grep '[PARSE_FAIL]' or 'Invalid announcement'"
if [[ "$dry_run" == true ]]; then
log_info "[DRY RUN] Would extract to: $output_dir/parse-failures.txt"
# Show sample of what would be extracted
log_info "Checking for matching log entries..."
local parse_fail_count invalid_announcement_count
parse_fail_count=$(eval "$journal_cmd" 2>/dev/null | grep -c '\[PARSE_FAIL\]' || echo "0")
parse_fail_count="${parse_fail_count//[^0-9]/}" # Strip non-numeric characters
parse_fail_count="${parse_fail_count:-0}"
invalid_announcement_count=$(eval "$journal_cmd" 2>/dev/null | grep 'Event rejected by write policy' | grep -c 'Invalid announcement' || echo "0")
invalid_announcement_count="${invalid_announcement_count//[^0-9]/}"
invalid_announcement_count="${invalid_announcement_count:-0}"
log_info "Found $parse_fail_count [PARSE_FAIL] entries"
log_info "Found $invalid_announcement_count 'Invalid announcement' rejections"
if [[ "$parse_fail_count" -eq 0 && "$invalid_announcement_count" -eq 0 ]]; then
log_warn "No matching entries found in logs."
log_warn "This is expected if ngit-grasp logging improvements are not yet deployed."
log_warn "See: docs/how-to/migrate-to-ngit-grasp.md (Dependencies section)"
fi
exit 0
fi
# Create output directory
mkdir -p "$output_dir"
local output_file="$output_dir/parse-failures.txt"
local temp_file
temp_file=$(mktemp)
# Extract and parse log entries using streaming (avoids loading all logs into memory)
log_info "Extracting log entries..."
# Create temp files for intermediate results
local temp_stderr temp_parse_fail temp_write_policy_rejection
temp_stderr=$(mktemp)
temp_parse_fail=$(mktemp)
temp_write_policy_rejection=$(mktemp)
# Extract [PARSE_FAIL] entries directly to temp file (streaming)
log_info " Searching for [PARSE_FAIL] entries..."
eval "$journal_cmd" 2>"$temp_stderr" | grep '\[PARSE_FAIL\]' > "$temp_parse_fail" || true
local journal_stderr
journal_stderr=$(cat "$temp_stderr" 2>/dev/null || true)
if [[ -n "$journal_stderr" ]]; then
log_warn "journalctl reported: $journal_stderr"
fi
# Extract "Event rejected by write policy" with "Invalid announcement" (streaming)
# NOTE: We only extract from write policy logs (hex IDs), not builder logs (note1 IDs)
# to avoid double-counting. Both log sources contain the same events.
log_info " Searching for write policy rejections..."
eval "$journal_cmd" 2>/dev/null | grep 'Event rejected by write policy' | grep 'Invalid announcement' > "$temp_write_policy_rejection" || true
rm -f "$temp_stderr"
# Check if we found anything
local parse_fail_line_count write_policy_line_count
parse_fail_line_count=$(wc -l < "$temp_parse_fail")
parse_fail_line_count="${parse_fail_line_count//[^0-9]/}"
write_policy_line_count=$(wc -l < "$temp_write_policy_rejection")
write_policy_line_count="${write_policy_line_count//[^0-9]/}"
log_info " Found $parse_fail_line_count [PARSE_FAIL] log lines"
log_info " Found $write_policy_line_count write policy rejection log lines"
local total_invalid_announcement_lines=$write_policy_line_count
if [[ "$parse_fail_line_count" -eq 0 && "$total_invalid_announcement_lines" -eq 0 ]]; then
log_warn "No matching entries found in logs."
log_warn ""
log_warn "This is expected if ngit-grasp logging improvements are not yet deployed."
log_warn "The script looks for:"
log_warn ""
log_warn " 1. [PARSE_FAIL] kind=30618 event_id=abc123 reason=\"...\" repo=myrepo npub=npub1..."
log_warn " 2. Event rejected by write policy event_id=... kind=30617 reason=Invalid announcement: ..."
log_warn ""
log_warn "See: docs/how-to/migrate-to-ngit-grasp.md (Dependencies section)"
log_warn ""
# Create empty output file with header comment
{
echo "# Parse failures and invalid announcements extracted from $service"
echo "# Time range: ${since_date:-beginning} to ${until_date:-now}"
echo "# Extracted: $(date -Iseconds)"
echo "#"
echo "# Includes:"
echo "# - [PARSE_FAIL] structured log entries"
echo "# - \"Invalid announcement\" rejections"
echo "#"
echo "# Format: event_id<TAB>kind<TAB>reason<TAB>repo<TAB>npub"
echo "# Note: repo and npub may be empty for some entries"
echo "#"
echo "# NOTE: No matching entries found."
echo "# This is expected if ngit-grasp logging improvements are not yet deployed."
} > "$output_file"
rm -f "$temp_parse_fail" "$temp_write_policy_rejection"
log_info "Created empty output file: $output_file"
exit 0
fi
# Write header
{
echo "# Parse failures and invalid announcements extracted from $service"
echo "# Time range: ${since_date:-beginning} to ${until_date:-now}"
echo "# Extracted: $(date -Iseconds)"
echo "#"
echo "# Includes:"
echo "# - [PARSE_FAIL] structured log entries"
echo "# - \"Invalid announcement\" rejections"
echo "#"
echo "# Format: event_id<TAB>kind<TAB>reason<TAB>repo<TAB>npub"
echo "# Note: repo and npub may be empty for some entries"
} > "$output_file"
# Parse [PARSE_FAIL] entries
log_info " Parsing [PARSE_FAIL] entries..."
local parse_fail_count=0
if [[ "$parse_fail_line_count" -gt 0 ]]; then
while IFS= read -r line; do
local parsed
parsed=$(parse_parse_fail_line "$line")
if [[ -n "$parsed" ]]; then
echo "$parsed" >> "$output_file"
parse_fail_count=$((parse_fail_count + 1))
fi
done < "$temp_parse_fail"
fi
# Parse write policy rejection entries
log_info " Parsing write policy rejection entries..."
local write_policy_count=0
if [[ "$write_policy_line_count" -gt 0 ]]; then
while IFS= read -r line; do
local parsed
parsed=$(parse_write_policy_rejection_line "$line")
if [[ -n "$parsed" ]]; then
echo "$parsed" >> "$output_file"
write_policy_count=$((write_policy_count + 1))
fi
done < "$temp_write_policy_rejection"
fi
local invalid_announcement_count=$write_policy_count
rm -f "$temp_parse_fail" "$temp_write_policy_rejection"
# Deduplicate by event_id (first column) - keep first occurrence
log_info " Deduplicating entries..."
local deduped_file
deduped_file=$(mktemp)
# Preserve header lines (starting with #) and deduplicate data lines
grep '^#' "$output_file" > "$deduped_file"
grep -v '^#' "$output_file" | sort -t$'\t' -k1,1 -u >> "$deduped_file"
mv "$deduped_file" "$output_file"
# Enrich with repo/npub from announcements.json if analysis root provided
# This is critical for usability - without it, action-required.txt shows
# event_id|kind instead of repo|npub, making parse failures unidentifiable
if [[ -n "$analysis_root" ]]; then
enrich_with_repo_npub "$output_file" "$analysis_root"
fi
# Filter to missing announcements only if analysis root provided
if [[ -n "$analysis_root" ]]; then
filter_to_missing_announcements "$output_file" "$analysis_root"
fi
# Count final entries (excluding header lines)
local count
count=$(grep -v '^#' "$output_file" | wc -l)
count="${count//[^0-9]/}" # Strip whitespace
count="${count:-0}"
rm -f "$temp_file"
# Summary
echo ""
log_info "=== Extraction Summary ==="
log_info "Service: $service"
log_info "Time range: ${since_date:-beginning} to ${until_date:-now}"
if [[ -n "$analysis_root" ]]; then
log_info "Filtered to: missing announcements only"
fi
log_success "Extracted $count total entries"
log_info " - [PARSE_FAIL] entries: $parse_fail_count"
log_info " - Invalid announcement rejections: $invalid_announcement_count"
if [[ -n "$analysis_root" ]]; then
log_info " (filtered from original extraction)"
fi
echo ""
log_info "Output file: $output_file"
if [[ $count -gt 0 ]]; then
echo ""
log_info "Sample entries (first 5):"
# Use a subshell to avoid SIGPIPE issues with set -e
# New format: event_id<TAB>kind<TAB>reason<TAB>repo<TAB>npub
(grep -v '^#' "$output_file" | head -5 | while IFS=$'\t' read -r event_id kind reason repo npub; do
echo " kind=$kind event_id=${event_id:0:16}... reason=\"${reason:0:60}...\""
done) || true
fi
# Breakdown by kind
if [[ $count -gt 0 ]]; then
echo ""
log_info "Breakdown by event kind:"
# Use a subshell to avoid SIGPIPE issues with set -e
# kind is now column 2
(grep -v '^#' "$output_file" | awk -F'\t' '{print $2}' | sort | uniq -c | sort -rn | while read -r cnt kind; do
echo " kind $kind: $cnt failures"
done) || true
fi
# Breakdown by reason pattern (for invalid announcements)
if [[ $invalid_announcement_count -gt 0 ]]; then
echo ""
log_info "Breakdown by reason pattern:"
# Extract the main reason type (before the colon details)
(grep -v '^#' "$output_file" | awk -F'\t' '{print $3}' | sed 's/:.*//' | sort | uniq -c | sort -rn | head -10 | while read -r cnt reason; do
echo " $reason: $cnt"
done) || true
fi
# Explicit success exit
exit 0
}
main "$@"