#!/usr/bin/env python3
# ==============================================================================
# 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.py -- shared structured-logging helper for SDP Python
# scripts, both cron-invoked (p4verify.py, update_limits.py) and trigger
# scripts fired directly by p4d from the trigger table (triggers/*.py).
#
# stdlib only, deliberately: trigger scripts must run with minimal external
# requirements at trigger time (documented project constraint), so this
# module takes no dependency beyond the standard library -- no sdputils.py,
# no P4Python, nothing third-party.
#
# NEVER WRITE TO STDOUT. p4d relays a trigger's stdout to the connected
# client, so a stray print() here becomes a user-visible message on, e.g.,
# `p4 group -i`. Every public entry point below is wrapped in a broad
# try/except so a logging bug can never fail the caller's real work, and
# diagnostics (if SDP_LOG_DEBUG=1) go to stderr, never stdout.
#
# One mode at a time (SDP_LOG_FORMAT env var, default "jsonl"), matching the
# bash helper's semantics exactly: "jsonl" appends one JSON object per run
# alongside the caller's own log; "prom" writes Prometheus textfile-collector
# gauges to $SDP_METRICS_DIR instead; "off" disables structured output.
# is_trigger=True callers always skip prom mode -- a trigger firing
# concurrently from many p4d processes rewriting one gauge file is
# meaningless (last-writer-wins, no counters).
#
# Usage (cron script):
# import sdp_structured_log as sdplog
# with sdplog.run("p4verify.py", logfile=summary_log_path) as ev:
# ev.add(depots_verified=12)
# ...
# if problems:
# ev.set_status("warning")
#
# Usage (trigger -- optional import, must degrade silently if this module
# hasn't shipped yet, e.g. mid-rollout when the Ansible-templated trigger
# lands before the tarball that carries this file):
# sys.path.insert(0, "/p4/common/bin")
# try:
# import sdp_structured_log as sdplog
# except Exception:
# sdplog = None
# ev = sdplog.init("keep_group_unset.py", is_trigger=True) if sdplog else None
# ...
# if ev:
# ev.add(user=user, action="rewrote")
# ev.emit(0)
# ------------------------------------------------------------------------------
import atexit
import datetime
import json
import os
import sys
import time
_PIPE_BUF_SAFE_LINE = 4000
_MAX_FIELD_LEN = 256
_MAX_ERROR_LEN = 900
_MAX_LABEL_LEN = 128
def _debug(msg):
if os.environ.get("SDP_LOG_DEBUG") == "1":
try:
sys.stderr.write("sdp_structured_log: %s\n" % msg)
except Exception:
pass
def _truncate(s, n):
s = str(s)
return s if len(s) <= n else s[:n]
class SDPEvent:
"""One in-flight terminal record. emit() is idempotent -- called at
most once regardless of how many of atexit/excepthook/run() reach it."""
def __init__(self, script, logfile=None, instance=None, mode=None, is_trigger=False):
self.script = script
self.logfile = logfile
self.instance = instance if instance is not None else os.environ.get("SDP_INSTANCE", "")
self.mode = mode if mode is not None else os.environ.get("SDP_LOG_FORMAT", "jsonl")
self.is_trigger = is_trigger
self.status = "success"
self.error = None
self.fields = {}
self.start = time.time()
self._emitted = False
def add(self, **fields):
try:
for k, v in fields.items():
self.fields[k] = v
except Exception as exc: # noqa: BLE001 - logging must never raise
_debug("add() failed: %s" % exc)
def set_status(self, status):
# Monotonic downgrade only, same rule as the bash helper: a later
# success can never mask an earlier warning/failure.
order = {"success": 0, "warning": 1, "failure": 2}
try:
if order.get(status, 0) > order.get(self.status, 0):
self.status = status
except Exception as exc: # noqa: BLE001
_debug("set_status() failed: %s" % exc)
def set_error(self, message):
try:
self.error = _truncate(message, _MAX_ERROR_LEN)
if self.status == "success":
self.status = "failure"
except Exception as exc: # noqa: BLE001
_debug("set_error() failed: %s" % exc)
def emit(self, exit_code=None):
if self._emitted:
return
self._emitted = True
try:
duration = time.time() - self.start
rc = 0 if exit_code is None else exit_code
if self.status == "success" and rc not in (0, None):
self.status = "failure"
if self.mode == "off":
return
if self.mode == "prom":
if not self.is_trigger:
_write_prom(self, rc, duration)
return
_write_jsonl(self, rc, duration)
except Exception as exc: # noqa: BLE001 - logging must never raise
_debug("emit() failed: %s" % exc)
def _structured_file(event):
if event.logfile:
base, ext = os.path.splitext(event.logfile)
if ext == ".log":
return base + ".jsonl"
return event.logfile + ".jsonl"
logs_dir = os.environ.get("LOGS", "/tmp")
name = event.script
if name.endswith(".py") or name.endswith(".sh"):
name = name.rsplit(".", 1)[0]
return os.path.join(logs_dir, name + ".jsonl")
def _write_jsonl(event, rc, duration):
path = _structured_file(event)
directory = os.path.dirname(path) or "."
if not os.path.isdir(directory) or not os.access(directory, os.W_OK):
return
record = {
"timestamp": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
"script": event.script,
"instance": str(event.instance),
"status": event.status,
"exit_code": rc,
"duration_seconds": round(duration, 3),
"error": event.error,
}
for k, v in event.fields.items():
if isinstance(v, (int, float)):
record[k] = v
else:
record[k] = _truncate(v, _MAX_FIELD_LEN)
line = json.dumps(record, separators=(",", ":"))
if len(line) > _PIPE_BUF_SAFE_LINE:
# Same PIPE_BUF safety rule as the bash helper: a single line over
# ~4KB is not guaranteed to append atomically. Drop fields (not the
# fixed prefix) until it fits, and say so.
minimal = dict(record)
minimal["truncated"] = True
for k in list(event.fields.keys()):
minimal.pop(k, None)
line = json.dumps(minimal, separators=(",", ":"))
if len(line) <= _PIPE_BUF_SAFE_LINE:
break
with open(path, "a") as f: # noqa: PTH123 - append, O_APPEND is the point
f.write(line + "\n")
def _metric_base(script):
name = "p4_sdp_" + os.path.splitext(script)[0]
return "".join(c if (c.isalnum() or c == "_") else "_" for c in name)
def _prom_label_escape(s):
s = _truncate(s, _MAX_LABEL_LEN)
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def _write_prom(event, rc, duration):
metrics_dir = os.environ.get("SDP_METRICS_DIR", "/p4/metrics")
if not os.path.isdir(metrics_dir) or not os.access(metrics_dir, os.W_OK):
return
base = _metric_base(event.script)
instance = _prom_label_escape(event.instance)
final_path = os.path.join(metrics_dir, base + ".prom")
tmp_path = "%s.%d" % (final_path, os.getpid())
success = 1 if event.status == "success" else 0
lines = [
"# HELP %s_success Whether the last run of this script succeeded (1) or not (0)." % base,
"# TYPE %s_success gauge" % base,
'%s_success{instance="%s"} %d' % (base, instance, success),
"# HELP %s_exit_code Exit code of the last run." % base,
"# TYPE %s_exit_code gauge" % base,
'%s_exit_code{instance="%s"} %s' % (base, instance, rc),
"# HELP %s_duration_seconds Duration of the last run, in seconds." % base,
"# TYPE %s_duration_seconds gauge" % base,
'%s_duration_seconds{instance="%s"} %.3f' % (base, instance, duration),
"# HELP %s_last_run_timestamp_seconds Unix time the last run finished." % base,
"# TYPE %s_last_run_timestamp_seconds gauge" % base,
'%s_last_run_timestamp_seconds{instance="%s"} %d' % (base, instance, time.time()),
]
if event.status != "success":
lines += [
"# HELP %s_status Non-success status of the last run (info-style; 1 when present)." % base,
"# TYPE %s_status gauge" % base,
'%s_status{instance="%s",status="%s",error="%s"} 1'
% (base, instance, _prom_label_escape(event.status), _prom_label_escape(event.error or "")),
]
for k, v in event.fields.items():
if isinstance(v, (int, float)):
key = "".join(c if (c.isalnum() or c == "_") else "_" for c in str(k))
lines.append('%s_%s{instance="%s"} %s' % (base, key, instance, v))
try:
with open(tmp_path, "w") as f: # noqa: PTH123
f.write("\n".join(lines) + "\n")
os.replace(tmp_path, final_path)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass # already replaced, or never created
def init(script=None, logfile=None, instance=None, mode=None,
install_signal_handlers=False, is_trigger=False):
"""One-shot setup: creates the event, wires atexit + excepthook, returns
the event so the caller can add()/set_status()/set_error() during the
run. emit() fires automatically at process exit; call it directly first
if an explicit exit code is known (SystemExit doesn't always carry one
cleanly through excepthook)."""
name = script or os.path.basename(sys.argv[0])
event = SDPEvent(name, logfile=logfile, instance=instance, mode=mode, is_trigger=is_trigger)
try:
atexit.register(event.emit)
except Exception as exc: # noqa: BLE001
_debug("atexit.register failed: %s" % exc)
previous_hook = sys.excepthook
def _hook(exc_type, exc_value, exc_tb):
try:
if exc_type is not None and exc_type is not SystemExit:
event.set_error("%s: %s" % (exc_type.__name__, exc_value))
event.set_status("failure")
except Exception: # noqa: BLE001
pass
return previous_hook(exc_type, exc_value, exc_tb)
try:
sys.excepthook = _hook
except Exception as exc: # noqa: BLE001
_debug("excepthook install failed: %s" % exc)
if install_signal_handlers:
try:
import signal
def _signal_handler(signum, _frame):
event.set_status("failure")
event.set_error("received signal %d" % signum)
event.emit(128 + signum)
signal.signal(signum, signal.SIG_DFL)
os.kill(os.getpid(), signum)
for sig in (getattr(signal, "SIGTERM", None), getattr(signal, "SIGINT", None)):
if sig is not None:
signal.signal(sig, _signal_handler)
except Exception as exc: # noqa: BLE001
_debug("signal handler install failed: %s" % exc)
return event
def log_event(script, status, exit_code, duration_seconds, error=None, **fields):
"""One-shot emit, matching the bash helper's sdp_log_event signature --
for a caller that manages its own error handling and just wants to
write one record without the init()/atexit machinery."""
try:
event = SDPEvent(script)
event.status = status
event.error = _truncate(error, _MAX_ERROR_LEN) if error else None
event.fields = dict(fields)
event.start = time.time() - float(duration_seconds or 0)
event.emit(exit_code)
except Exception as exc: # noqa: BLE001
_debug("log_event() failed: %s" % exc)
class run:
"""Context manager form: with sdp_structured_log.run("script.py") as ev:
... . Records a failure status + formatted exception on any exception
other than SystemExit (whose .code becomes the exit_code), then
re-raises / lets SystemExit propagate unchanged either way."""
def __init__(self, script, logfile=None, **kw):
self._event = init(script, logfile=logfile, **kw)
def __enter__(self):
return self._event
def __exit__(self, exc_type, exc_value, _exc_tb):
try:
if exc_type is None:
self._event.emit(0)
elif exc_type is SystemExit:
code = exc_value.code if isinstance(exc_value.code, int) else (1 if exc_value.code else 0)
self._event.emit(code)
else:
self._event.set_status("failure")
self._event.set_error("%s: %s" % (exc_type.__name__, exc_value))
self._event.emit(1)
except Exception: # noqa: BLE001
pass
return False # never swallow the caller's exception
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #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. |