Files
tinker_tickets/helpers/TicketHash.php
T
jaredandClaude Opus 5.5 aad3e8eb44
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP (phpcs PSR-12) (push) Successful in 27s
Lint / PHP requirements (version + extensions) (push) Successful in 33s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 9s
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 1m12s
Security / PHP Security (semgrep) (push) Successful in 3m26s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 35s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / Deploy (pull_request) Skipped
Lint / Notify on failure (pull_request) Skipped
Security / PHP Security (semgrep) (pull_request) Successful in 1m48s
Key network dedup tickets on the problem, not just the host (#116)
Network tickets never set issue_subtype, so every automated network alert
for a host hashed alike: Gandalf's "Interface enp3s0 link-down" for micro1
matched closed reboot ticket 993441773, got noted there as a recurrence,
and never got its own ticket.

Automated network tickets now key on the optional `check_code` payload
field when sent (it must name the instance, e.g. iface_down:enp3s0),
otherwise on the title text outside the [tags], normalised with digits
kept. Manual tickets and every other category hash exactly as before.

The hash logic moves to helpers/TicketHash.php so the new
scripts/rehash_network_tickets.php can re-key open network tickets once
after deploy (--dry-run first). Closed tickets keep their old hash so they
stop catching unrelated alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:35:48 -05:00

187 lines
9.2 KiB
PHP

<?php
/**
* Dedup hash for tickets created through create_ticket_api.php.
*
* Two reports with the same hash land on the same ticket (updated while open,
* reopened when closed), so the components below decide what counts as "the
* same problem". Changing them re-keys existing tickets: see
* scripts/rehash_network_tickets.php for how #116 handled that.
*/
class TicketHash
{
/**
* The stable components the hash is built from.
*
* @param array $data Request payload: title, plus optional serial / check_code.
*/
public static function components(array $data): array
{
$title = (string)($data['title'] ?? '');
// Prefer explicit serial from payload; fall back to extracting device path from title
// for backwards compatibility with older hwmonDaemon versions.
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
? (string)$data['serial']
: null;
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
// Extract first bracketed tag as hostname/source
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
$hostname = $hostMatches[1] ?? '';
// Detect issue category and optional sub-type
$issueCategory = '';
$issueSubtype = '';
$isClusterWide = false;
if (stripos($title, 'SMART issues') !== false) {
$issueCategory = 'smart';
} elseif (stripos($title, 'ZFS pool') !== false) {
$issueCategory = 'zfs';
// Extract pool name so each pool gets its own ticket
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
$issueSubtype = 'pool_state_' . $poolName;
} elseif (stripos($title, 'usage') !== false) {
$issueSubtype = 'pool_usage_' . $poolName;
} elseif (stripos($title, 'errors') !== false) {
$issueSubtype = 'pool_errors_' . $poolName;
} else {
$issueSubtype = 'pool_' . $poolName;
}
}
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
$issueCategory = 'storage';
// Include the LXC container ID so each container gets its own ticket
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
$issueSubtype = 'lxc_' . $lxcMatch[1];
}
} elseif (stripos($title, 'memory') !== false) {
$issueCategory = 'memory';
} elseif (stripos($title, 'cpu') !== false) {
$issueCategory = 'cpu';
} elseif (stripos($title, 'network') !== false) {
$issueCategory = 'network';
// One ticket per kind of network problem per host, not one per
// host. With an empty subtype every network alert for a host
// (reboot, NIC link-down, cable mismatch, ...) hashed alike, so an
// interface alert landed on an unrelated closed reboot ticket
// (#116). Prefer the reporter's check_code when sent; it must name
// the instance too (e.g. "iface_down:enp3s0"), not just the alert
// type. Otherwise key on the title text outside the [tags]; the
// reporters' headings are stable per problem, and digits are kept
// because they identify it (enp3s0, cable C07). Manual tickets
// are already keyed on their full title, so they are left as-is.
$checkCode = isset($data['check_code']) && is_string($data['check_code']) ? $data['check_code'] : '';
if (stripos($title, '[auto]') === false) {
// manual: unchanged hash
} elseif ($checkCode !== '' && preg_match('/^[A-Za-z0-9_.:-]{1,80}$/', $checkCode)) {
$issueSubtype = 'net_check_' . strtolower($checkCode);
} else {
$heading = trim(preg_replace('/\[[^\]]*\]/', '', $title), " -\t");
$slug = trim(preg_replace('/[^a-z0-9]+/', '_', strtolower($heading)), '_');
if ($slug !== '') {
$issueSubtype = 'net_' . substr($slug, 0, 80);
}
}
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
$issueCategory = 'ceph';
if (
stripos($title, '[cluster-wide]') !== false ||
stripos($title, 'HEALTH_ERR') !== false ||
stripos($title, 'HEALTH_WARN') !== false ||
stripos($title, 'cluster usage') !== false
) {
$isClusterWide = true;
}
// Normalize the specific Ceph warning type so different warnings get distinct tickets
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
$issueSubtype = 'bluestore_slow';
} elseif (stripos($title, 'clock skew') !== false) {
$issueSubtype = 'clock_skew';
} elseif (stripos($title, 'cluster usage') !== false) {
$issueSubtype = 'usage';
} elseif (stripos($title, 'OSD down') !== false || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
// Include the specific OSD ID so each individual OSD gets its own ticket
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
$issueSubtype = 'osd_down_' . $osdMatch[1];
} else {
$issueSubtype = 'osd_down';
}
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
$issueSubtype = 'health_err';
} else {
// Any other warning gets its own ticket. Previously every other
// HEALTH_WARN shared one empty subtype, so unrelated warnings
// (noout, nearfull, insecure keys, ...) collapsed onto a single
// ticket and overwrote its title in turn every run (#112).
// Prefer Ceph's own health-check code when the reporter sends
// it; otherwise key on the warning text with counts stripped.
$checkCode = isset($data['check_code']) && is_string($data['check_code']) ? $data['check_code'] : '';
if ($checkCode !== '' && preg_match('/^[A-Z0-9_]{1,64}$/', $checkCode)) {
$issueSubtype = 'check_' . strtolower($checkCode);
} elseif (preg_match('/HEALTH_WARN:\s*([^\[]+)/i', $title, $warnMatch)) {
$warnText = strtolower(explode(':', $warnMatch[1])[0]);
$warnText = trim(preg_replace('/[^a-z]+/', '_', $warnText), '_');
if ($warnText !== '') {
$issueSubtype = 'warn_' . $warnText;
}
}
}
}
// Include source type so automated tickets never collide with manual ones
$sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual';
// Build stable components
$stableComponents = [
'source_type' => $sourceType,
'issue_category' => $issueCategory,
'issue_subtype' => $issueSubtype,
'environment_tags' => (function () use ($title) {
// Extract each [bracketed] tag, then keep the known environment ones.
// (explode('][') leaves brackets stuck to the first/last tag, so e.g.
// "[production] ..." never matched and the env tag was dropped from the
// dedup hash — letting prod and staging issues collide onto one ticket.)
preg_match_all('/\[([^\]]+)\]/', $title, $m);
return array_values(array_filter(
$m[1],
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'], true)
));
})(),
];
// Manual tickets should be unique by title (so different software installs don't collide)
if ($sourceType === 'manual') {
$stableComponents['title'] = $title;
}
// Include hostname for node-specific issues
if (!$isClusterWide) {
$stableComponents['hostname'] = $hostname;
}
// Include drive identifier for drive-specific tickets.
// Use serial when available (stable across reboots/reshuffles); fall back to
// device path for tickets created before serial was added to the payload.
if ($isDriveTicket) {
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
}
sort($stableComponents['environment_tags']);
return $stableComponents;
}
/** SHA-256 dedup hash of the payload's stable components. */
public static function generate(array $data): string
{
return hash('sha256', json_encode(self::components($data), JSON_UNESCAPED_SLASHES));
}
}