#!/bin/bash
# shellcheck shell=bash
#==============================================================================
# Copyright and license info is available in the LICENSE file included with
# the Server Deployment Package (SDP), and also available online:
# https://swarm.workshop.perforce.com/projects/perforce-software-sdp/view/main/LICENSE
#------------------------------------------------------------------------------
#
# sdp_structured_log.sh -- shared structured-logging helper for SDP
# maintenance scripts. Sourced automatically by backup_functions.sh.
#
# One mode at a time, not simultaneous dual-writing: SDP_LOG_FORMAT (set in
# p4_vars) selects jsonl (default), prom, or off.
# jsonl - one JSON object per run, appended alongside the calling script's
# own $LOGFILE (same base name, .jsonl extension instead of .log).
# prom - Prometheus textfile-collector gauges written to $SDP_METRICS_DIR
# instead, one file per script, atomically (write-to-tempfile-then-
# mv), matching the convention perforce-sdp-monitoring's own
# p4_healthcheck.sh.j2 already uses.
# off - no structured output at all.
#
# Usage, from a script that already sources p4_vars + backup_functions.sh:
# LOGFILE="$LOGS/myscript.log"
# log_init
# sdp_log_init # after LOGFILE/log_init, before script logic
# ...
# sdp_add_field key value # zero or more, any point before exit
# ... # exit normally, via die(), or via a signal --
# # the terminal record is emitted exactly
# # once regardless, from the EXIT trap.
#
# A script that wants to emit one record itself, without the trap machinery
# (e.g. because it has its own error handling already), can call
# sdp_log_event directly instead of sdp_log_init.
#
# Every write is best-effort and never fatal: a logging failure must not turn
# a successful checkpoint into a failed one, and must never write to stdout
# -- some callers (trigger scripts) cannot have anything on stdout at all,
# since p4d relays a trigger's stdout to the connected client.
#------------------------------------------------------------------------------
[[ -n "${_SDP_STRUCTURED_LOG_LOADED:-}" ]] && return 0
_SDP_STRUCTURED_LOG_LOADED=1
#------------------------------------------------------------------------------
# sdp_log_init [script-name]
#
# Call once, after LOGFILE (if any) is set and after log_init. Initializes
# per-run state and installs the trap that guarantees exactly one terminal
# record gets emitted regardless of how the script exits -- clean exit,
# explicit exit N, die(), an unbound variable under set -u, a set -e command
# failure, or HUP/INT/TERM. (SIGKILL/OOM cannot be trapped by any shell; that
# is a documented limitation, not a gap in this helper.)
#
# Placement matters for wrapper scripts like run_if_master.sh: call this
# AFTER any role-gate check that should exit silently (e.g. "this host is
# not a master, skip"), so a legitimate skip emits nothing.
#------------------------------------------------------------------------------
function sdp_log_init () {
SDP_SCRIPT_NAME="${1:-${0##*/}}"
SDP_EVENT_START=$(_sdp_now)
SDP_EVENT_STATUS=success
SDP_EVENT_ERROR=
SDP_EVENT_EXTRA=()
_SDP_EVENT_EMITTED=0
trap '_sdp_on_exit $?' EXIT
trap '_sdp_trap_err $?' ERR
trap '_sdp_trap_signal HUP' HUP
trap '_sdp_trap_signal INT' INT
trap '_sdp_trap_signal TERM' TERM
}
#------------------------------------------------------------------------------
# sdp_add_field <key> <value> (or a single "key=value" argument)
#
# Accumulates script-specific "what happened" data for the terminal record.
# Written flat alongside the fixed fields -- no nested object. Numeric
# values become numeric fields; anything else is quoted (jsonl) or dropped
# (prom -- a gauge value must be a number).
#------------------------------------------------------------------------------
function sdp_add_field () {
local kv
if [[ $# -ge 2 ]]; then
kv="$1=$2"
else
kv=$1
fi
SDP_EVENT_EXTRA+=("$kv")
}
#------------------------------------------------------------------------------
# sdp_set_status <success|warning|failure>
#
# Monotonic downgrade only (success -> warning -> failure), so a later
# success can never mask an earlier warning or failure from the same run.
#------------------------------------------------------------------------------
function sdp_set_status () {
local new=${1:-success}
local cur=${SDP_EVENT_STATUS:-success}
case "$cur:$new" in
*:failure) SDP_EVENT_STATUS=failure ;;
success:warning) SDP_EVENT_STATUS=warning ;;
failure:*|warning:success|warning:warning) : ;; # never upgrade / already there
*) SDP_EVENT_STATUS=$new ;;
esac
}
#------------------------------------------------------------------------------
# sdp_set_error <message>
#------------------------------------------------------------------------------
function sdp_set_error () {
SDP_EVENT_ERROR=${1:0:900}
}
#------------------------------------------------------------------------------
# sdp_elapsed
#
# Echoes elapsed seconds (float) since sdp_log_init. Falls back to the
# shell's own $SECONDS (integer) if the platform's date lacks %N (e.g. BSD).
#------------------------------------------------------------------------------
function sdp_elapsed () {
local start=${SDP_EVENT_START:-}
[[ -z "$start" ]] && { echo 0; return 0; }
case "$start" in
*N) echo "$SECONDS"; return 0 ;;
esac
"${AWK:-awk}" -v s="$start" -v n="$(_sdp_now)" 'BEGIN{printf "%.3f", n-s}' 2>/dev/null || echo "$SECONDS"
}
#------------------------------------------------------------------------------
# sdp_log_event <status> <exit_code> <duration_seconds> [error] [key=value ...]
#
# The single writer, dispatching on SDP_LOG_FORMAT. Never fatal.
#------------------------------------------------------------------------------
function sdp_log_event () {
local status=${1:-success} exit_code=${2:-0} duration=${3:-0} error=${4:-}
local extra=()
if [[ $# -gt 4 ]]; then
extra=("${@:5}")
fi
case "${SDP_LOG_FORMAT:-jsonl}" in
off) : ;;
prom) _sdp_write_prom "$status" "$exit_code" "$duration" "$error" "${extra[@]+"${extra[@]}"}" ;;
*) _sdp_write_jsonl "$status" "$exit_code" "$duration" "$error" "${extra[@]+"${extra[@]}"}" ;;
esac
# Only mirror into the human-readable log when there IS one. log()'s own
# fallback for no $LOGFILE is to echo to stdout (correct for log()'s
# other, general-purpose callers) -- but that is exactly the "never
# write to stdout" invariant this module documents at the top of this
# file, and the run_if_{master,edge,replica,broker,proxy}.sh wrapper
# family deliberately never sets LOGFILE (they share one .jsonl keyed by
# script name instead of a *.log sibling - see _sdp_structured_file).
# Confirmed live: every cron run through any of those wrappers was
# echoing this line to stdout, success or not, and cron mails on any
# output -- not gated on LOGFILE, this fired on 100% of runs, not just
# failures.
if declare -F log >/dev/null 2>&1 && [[ -n "${LOGFILE:-}" ]]; then
log "sdp_event script=${SDP_SCRIPT_NAME:-${0##*/}} status=$status exit_code=$exit_code duration=${duration}s" 2>/dev/null
fi
return 0
}
#==============================================================================
# Private helpers below. Names are prefixed _sdp_ specifically so they can
# never collide with any script's own locals -- e.g. run_if_master.sh and
# friends already define a local check_vars() that shadows
# backup_functions.sh's own, so name collision here is a real, observed risk
# class, not a theoretical one.
#==============================================================================
function _sdp_now () {
date +%s.%N 2>/dev/null
}
function _sdp_json_escape () {
local s=$1
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\t'/\\t}
s=${s//$'\r'/\\r}
s=${s//$'\n'/\\n}
printf '%s' "$s"
}
# The jsonl sibling of the calling script's own log, or a fixed fallback
# under $LOGS when no LOGFILE is set (e.g. the run_if_*.sh wrappers, which
# share one cron_wrapper.jsonl rather than each defining their own LOGFILE).
function _sdp_structured_file () {
if [[ -n "${LOGFILE:-}" ]]; then
case "$LOGFILE" in
*.log) echo "${LOGFILE%.log}.jsonl" ;;
*) echo "${LOGFILE}.jsonl" ;;
esac
else
echo "${LOGS:-/tmp}/${SDP_SCRIPT_NAME:-cron_wrapper}.jsonl" | sed 's/\.sh\.jsonl$/.jsonl/'
fi
}
# PIPE_BUF rule: a single printf of a line <= 4096 bytes to a file opened
# O_APPEND is atomic on Linux, which is what makes concurrent appends to a
# shared .jsonl (e.g. daily_checkpoint.sh + rotate_journal.sh sharing
# checkpoint.jsonl) safe without any locking. Enforced here by truncating
# the error message and every extra value, and capping the total line
# length -- never remove these caps without replacing the append with a
# real lock.
function _sdp_write_jsonl () {
local status=$1 exit_code=$2 duration=$3 error=$4
shift 4 || return 0
local file dir
file=$(_sdp_structured_file)
dir=$(dirname "$file")
[[ -d "$dir" && -w "$dir" ]] || return 1
local error_json
if [[ -z "$error" ]]; then
error_json=null
else
error_json="\"$(_sdp_json_escape "${error:0:900}")\""
fi
local line
# %:z (not `date -Iseconds`/%z) to force a colon-separated UTC offset
# (+00:00, not +0000) - matches sdp_structured_log.py's
# datetime.isoformat(), which always includes the colon. `-Iseconds`
# does NOT reliably match: on GNU coreutils 8.22 (RHEL7-family, still
# in the field) it emits +0000, no colon - confirmed live
# (5332stage.p4one.ea.com) - while telegraf's json_time_format for
# *.jsonl (perforceone.j2) is the single Go layout "2006-01-02T15:04:05Z07:00",
# which requires the colon and rejects +0000 as unparseable, silently
# dropping every line this script emits. %:z is unambiguous regardless
# of coreutils version.
line=$(printf '{"timestamp":"%s","script":"%s","instance":"%s","status":"%s","exit_code":%s,"duration_seconds":%s,"error":%s' \
"$(date '+%Y-%m-%dT%H:%M:%S%:z')" \
"$(_sdp_json_escape "${SDP_SCRIPT_NAME:-${0##*/}}")" \
"$(_sdp_json_escape "${SDP_INSTANCE:-}")" \
"$(_sdp_json_escape "$status")" \
"${exit_code:-0}" \
"${duration:-0}" \
"$error_json")
local kv k v total=${#line}
for kv in "$@"; do
[[ "$kv" == *=* ]] || continue
k=${kv%%=*}
v=${kv#*=}
v=${v:0:256}
if [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
kv=",\"$(_sdp_json_escape "$k")\":$v"
else
kv=",\"$(_sdp_json_escape "$k")\":\"$(_sdp_json_escape "$v")\""
fi
if (( total + ${#kv} > 3900 )); then
line+=',"truncated":true'
break
fi
line+="$kv"
total=$(( total + ${#kv} ))
done
line+='}'
printf '%s\n' "$line" >> "$file" 2>/dev/null
}
function _sdp_metric_base () {
local name="p4_sdp_${SDP_SCRIPT_NAME%.*}"
name=${name//[^a-zA-Z0-9_]/_}
printf '%s' "$name"
}
function _sdp_prom_label_escape () {
local s=${1:0:128}
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\n'/\\n}
printf '%s' "$s"
}
function _sdp_write_prom () {
local status=$1 exit_code=$2 duration=$3 error=$4
shift 4 || return 0
local dir="${SDP_METRICS_DIR:-/p4/metrics}"
[[ -d "$dir" && -w "$dir" ]] || return 1
local base instance file tmp success
base=$(_sdp_metric_base)
instance=$(_sdp_prom_label_escape "${SDP_INSTANCE:-}")
file="${dir}/${base}.prom"
tmp="${file}.$$"
success=1
[[ "$status" == success ]] || success=0
{
echo "# HELP ${base}_success Whether the last run of this script succeeded (1) or not (0)."
echo "# TYPE ${base}_success gauge"
echo "${base}_success{instance=\"$instance\"} $success"
echo "# HELP ${base}_exit_code Exit code of the last run."
echo "# TYPE ${base}_exit_code gauge"
echo "${base}_exit_code{instance=\"$instance\"} ${exit_code:-0}"
echo "# HELP ${base}_duration_seconds Duration of the last run, in seconds."
echo "# TYPE ${base}_duration_seconds gauge"
echo "${base}_duration_seconds{instance=\"$instance\"} ${duration:-0}"
echo "# HELP ${base}_last_run_timestamp_seconds Unix time the last run finished."
echo "# TYPE ${base}_last_run_timestamp_seconds gauge"
echo "${base}_last_run_timestamp_seconds{instance=\"$instance\"} $(date +%s)"
if [[ "$status" != success ]]; then
echo "# HELP ${base}_status Non-success status of the last run (info-style; 1 when present)."
echo "# TYPE ${base}_status gauge"
echo "${base}_status{instance=\"$instance\",status=\"$(_sdp_prom_label_escape "$status")\",error=\"$(_sdp_prom_label_escape "$error")\"} 1"
fi
# Numeric extras only -- a gauge value must be a number. Non-numeric
# extras (e.g. a filename) are jsonl-only.
local kv k v
for kv in "$@"; do
[[ "$kv" == *=* ]] || continue
k=${kv%%=*}
v=${kv#*=}
if [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
k=${k//[^a-zA-Z0-9_]/_}
echo "${base}_${k}{instance=\"$instance\"} $v"
fi
done
} > "$tmp" 2>/dev/null
mv -f "$tmp" "$file" 2>/dev/null
}
# EXIT trap: remove this PID's own in-flight .prom temp file if the script
# died mid-write. This plus the opportunistic self-heal below is why no cron
# sweeper is needed (one was removed 2026-08-27; do not reinstate it):
# node_exporter's textfile collector only reads files ending literally in
# ".prom", so a leftover "<name>.prom.<pid>" is invisible to scraping either
# way -- this is disk hygiene, not a metrics-correctness fix.
function _sdp_prom_cleanup () {
local dir="${SDP_METRICS_DIR:-/p4/metrics}"
[[ -d "$dir" ]] || return 0
local base
base=$(_sdp_metric_base)
rm -f "${dir}/${base}.prom.$$" 2>/dev/null
# Opportunistic self-heal for a PRIOR run's stray temp (kill -9, OOM):
# scoped to this exact script's own filename prefix (never another
# script's temp) and to files older than a day (never a live sibling
# process's in-flight temp, even under PID reuse).
find "$dir" -maxdepth 1 -name "${base}.prom.[0-9]*" -mtime +1 -delete 2>/dev/null
return 0
}
function _sdp_kill_log_mirror () {
if [[ -n "${LOGTAIL_PID:-}" ]]; then
sleep 0.3
kill "$LOGTAIL_PID" 2>/dev/null
LOGTAIL_PID=
fi
}
function _sdp_emit_terminal_event () {
local rc=${1:-0}
[[ "${_SDP_EVENT_EMITTED:-0}" == 1 ]] && return 0
_SDP_EVENT_EMITTED=1
if [[ "${SDP_EVENT_STATUS:-success}" == success && "$rc" != 0 ]]; then
SDP_EVENT_STATUS=failure
fi
local duration
duration=$(sdp_elapsed)
sdp_log_event "${SDP_EVENT_STATUS:-success}" "$rc" "$duration" "${SDP_EVENT_ERROR:-}" "${SDP_EVENT_EXTRA[@]+"${SDP_EVENT_EXTRA[@]}"}"
}
# Consolidated EXIT handler. Runs whether the script exits cleanly, via
# die(), via an unbound variable under set -u, via a set -e failure (through
# the ERR trap below setting status first), or via a signal (through
# _sdp_trap_signal's exit $((128+n)) below, which itself fires this).
# SIGKILL/OOM cannot be trapped by any shell -- documented limitation, not a
# gap here.
function _sdp_on_exit () {
local rc=${1:-$?}
if [[ -n "${SDP_SCRIPT_NAME:-}" ]]; then
_sdp_emit_terminal_event "$rc"
_sdp_prom_cleanup
fi
# Carries forward log_init()'s own interactive tail-mirror-kill behavior:
# sdp_log_init's trap installation (above) runs after log_init's, so this
# handler replaces log_init's inline EXIT trap for any script that calls
# both (the documented calling order) -- this line is what keeps that
# cleanup from being lost rather than a change to log_init itself.
_sdp_kill_log_mirror
}
function _sdp_trap_err () {
local rc=${1:-$?}
sdp_set_status failure
sdp_set_error "command failed (exit $rc): ${BASH_COMMAND:-unknown} at line ${BASH_LINENO[0]:-?}"
}
function _sdp_trap_signal () {
local sig=$1
local n=0
case "$sig" in
HUP) n=1 ;;
INT) n=2 ;;
TERM) n=15 ;;
esac
sdp_set_status failure
sdp_set_error "received SIG$sig"
# exit (rather than re-raising the real signal) is what makes this fire
# the EXIT trap above -- the source of the "exactly once, however it
# dies" guarantee for signals specifically.
exit $(( 128 + n ))
}
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #4 | 33624 | Russell C. Jackson (Rusty) |
sdp_structured_log.sh: force colon-separated UTC offset in timestamps date -Iseconds does not reliably include a colon in the UTC offset - confirmed live (5332stage.p4one.ea.com): GNU coreutils 8.22 (RHEL7-family, still in the field) emits +0000, not +00:00. telegraf's json_time_format for the *.jsonl tail input (perforceone.j2, gce_telegraf) uses the Go layout "...Z07:00", which requires the colon and silently drops any line without it - every bash-sourced *.jsonl line (update_limits.sh siblings, run_if_master.sh, verify_shelves.sh, rotate_journal.sh, etc.) was being rejected as a malformed log line, so none of this data ever reached ADX. Python-sourced *.jsonl (update_limits.py, via datetime.isoformat()) already emitted the colon correctly and was unaffected. Now forces %:z explicitly instead of relying on -Iseconds. |
||
| #3 | 33531 | Russell C. Jackson (Rusty) | Fix sdp_structured_log.sh nounset-unsafe empty-array expansion (bash <4.4 unbound variable on SDP_EVENT_EXTRA/extra) | ||
| #2 | 33396 | Russell C. Jackson (Rusty) |
sdp_log_event: don't echo the human-log mirror to stdout with no LOGFILE The run_if_{master,edge,replica,broker,proxy}.sh wrapper family deliberately never sets LOGFILE (they share one .jsonl keyed by script name instead of a *.log sibling). sdp_log_event's convenience mirror into the human-readable log via log() assumed a LOGFILE was always set; log()'s own fallback behavior with none is to echo to stdout - exactly the "never write to stdout" invariant this module documents at its own header, silently violated on every single cron run through any of those five wrappers, success or failure. Confirmed live: a cron email from run_if_master.sh (subject line naming the wrapped command, body "... sdp_event script=run_if_master.sh status=success exit_code=0 duration=0.406s") landed on every run, not just failures - cron mails on any stdout output regardless of exit code, and this fired on 100% of runs since it was never gated on LOGFILE actually being set. Fix: only call log() when LOGFILE is non-empty. Verified both ways: run_if_master.sh's case (no LOGFILE) is now completely silent on a successful run; daily_checkpoint.sh's case (LOGFILE set) still gets the sdp_event line mirrored into checkpoint.log exactly as before - no regression to the intended behavior for scripts that do have a log file. |
||
| #1 | 33340 | Russell C. Jackson (Rusty) |
Add structured logging (JSONL/Prometheus) to SDP maintenance scripts Failure has historically gone silently unlogged in these scripts while success was well-logged: verify_shelves.sh had no error detection at all, most other scripts only log a human-readable narrative with nothing machine-readable, and nothing here is consumable by monitoring tools (telegraf, Datadog, Prometheus) without a bespoke parser per script. Adds one shared bash helper (sdp_structured_log.sh, sourced automatically by backup_functions.sh) and one shared stdlib-only Python module (sdp_structured_log.py, importable by both cron scripts and trigger scripts). One mode at a time, selected by the new SDP_LOG_FORMAT p4_vars setting (jsonl by default, or prom for sites running Prometheus instead, or off) - never simultaneous dual-writing. jsonl mode appends one JSON object per run alongside each script's existing .log file; prom mode writes Prometheus textfile-collector gauges to $SDP_METRICS_DIR (default /p4/metrics, matching the existing p4prometheus_metrics_dir convention in the perforce-sdp-monitoring role) using the same atomic write-then-mv convention that role's own health-check scripts already use. Success/failure capture is trap-based (bash: EXIT/ERR/HUP/INT/TERM, consolidated so exactly one terminal record is emitted regardless of how a script exits) / atexit-based (Python), specifically so a script dying unexpectedly - an unbound variable, a set -e failure, a signal - still gets recorded rather than silently vanishing. die() is annotated (2 lines) rather than made to emit directly, so every one of the ~40 scripts that source backup_functions.sh gets failure-path coverage automatically once they call the new sdp_log_init, with no per-script failure plumbing needed. Also fixes verify_shelves.sh, which had no error detection whatsoever: `p4 changes`'s exit code was discarded and consumed by an unquoted `for` loop, so a failed listing produced an empty loop and exit 0 - a total failure was indistinguishable from "no shelves to verify". `p4 verify`'s own exit code was likewise never checked, and the script rm -f'd its own logfile every run (after log_init had already started tailing it interactively, which broke that too). Added the missing check_vars/set_vars/check_uid/p4login preflight that every other script here already has. Restructures the run_if_{master,edge,replica,broker,proxy}.sh cron wrapper family: each previously ended with `exec "$@"`, which replaces the wrapper's own process image, so no trap could ever fire and this wrapper's own success/failure was structurally impossible to record. Now a normal call + $? capture, still propagating the wrapped command's exit code unchanged. This alone gives structured coverage for every cron job routed through these wrappers, before any individual wrapped script is itself instrumented. Also fixes a related silent gap in the master/edge/replica variants: SERVER_TYPE matching neither the proxy skip nor the expected role fell through to an unexplained implicit exit 1 - now recorded as an explicit failure naming the mismatch, instead of a bare unexplained nonzero exit cron would mail with no context. triggers/keep_group_unset.py: fixed its stale `#!/usr/bin/env python` shebang to python3 (the body already requires it via os.replace(), 3.3+ only), and added a guarded structured-logging record. Behavior preservation is deliberate here: an unexpected exception still rejects the p4 group form edit exactly as before (fail-closed) - the change only adds a logged record explaining why, never stdout (p4d relays a trigger's stdout to the connected client), and degrades to a silent no-op if sdp_structured_log.py hasn't been deployed yet (it ships in the same tarball as this trigger but via a different deploy path than the Ansible-templated copy of this same file - see the update-tgz.sh changelist that follows this one). Extends rotate_last_run_logs/remove_old_logs to cover the new .jsonl siblings, including adding verify_shelves.log/.jsonl to the KEEPLOGS cleanup list - it was absent from that list entirely before (masked by the rm -f bug this same change removes), so without this addition it would have grown unbounded. |