// Add observational hooks to the verified S28 routing layout, without replacing routing or billing. import { readFileSync, writeFileSync } from 'node:fs'; const [source, output] = process.argv.slice(2); if (!source || !output) throw new Error('Usage: node instrument-caller-analytics.mjs source.cfg output.cfg'); let cfg = readFileSync(source, 'utf8').replaceAll('\r\n', '\n').replace(/^\uFEFF/, ''); if (cfg.includes('CRA_INSTRUMENTATION_VERSION=2')) throw new Error('Already instrumented at version 2'); function insert(marker, extra, after = false) { if (cfg.split(marker).length !== 2) throw new Error(`Unsupported routing layout: ${marker}`); cfg = cfg.replace(marker, after ? marker + extra : extra + marker); } const versionMarker = '# CRA_INSTRUMENTATION_VERSION=2\n'; const registerStats = () => insert('modparam("statistics", "variable", "s28_cdr_xadd_error_total/no_reset")', '\nmodparam("statistics", "variable", "cra_event_total/no_reset")', true); const ackHook = ` if (is_method("ACK")) {\n $var(cra_kind)="TALK_START"; $var(cra_code)=200; $var(cra_source)="dialog-acked"; route(CRA_EMIT_DLG);\n }\n`; if (cfg.includes('CRA1|')) { registerStats(); insert(' if (has_totag()) {\n', ackHook, true); cfg = cfg .replace('$var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);', '$var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);\n $var(cra_kind)="CLOSE"; $var(cra_source)="platform-closed"; route(CRA_EMIT_AVP);') .replace('$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="dialog-closed"; route(CRA_EMIT_DLG);') .replace('$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="dialog-timeout"; route(CRA_EMIT_DLG);') .replace('$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);', '$var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG);\n $var(cra_kind)="CLOSE"; $var(cra_source)="attempts-exhausted"; route(CRA_EMIT_DLG);') .replaceAll('xlog("L_NOTICE", "CRA1|', 'update_stat("cra_event_total", 1);\n xlog("L_NOTICE", "CRA1|'); writeFileSync(output, versionMarker + cfg, 'utf8'); console.log('Analytics observation hooks upgraded to version 2; OpenSIPS config validation is required before activation.'); process.exit(0); } registerStats(); insert(' if (has_totag()) {\n', ackHook, true); const metadata = { uid: '$ci + "~" + $ft + "~" + $TS', callid: '$ci', customer: '$var(s28_customer_id)', gateway: '$var(s28_gateway_id)', vendor: '$var(s28_vendor_id)', vendorGateway: '$var(s28_vendor_gateway_id)', caller: '$fU', landing: '$var(s28_landing_caller)', callee: '$var(s28_real_callee)', city: '$var(s28_callee_city_code)', carrier: '$var(s28_callee_operator)', startsec: '$Ts', startmicro: '$Tsm', attempt: '"none"' }; const init = Object.entries(metadata).map(([k,v]) => ` $avp(cra_${k}) = ${v};`).join('\n'); insert(' if ($var(s28_decision) != "allow") {', `${init}\n $var(cra_kind)="START"; $var(cra_code)=0; $var(cra_source)="opensips"; route(CRA_EMIT_AVP);\n\n`); const bind = Object.keys(metadata).map(k => ` $dlg_val(cra_${k}) = $avp(cra_${k});`).join('\n'); insert(' $dlg_val(s28_customer_id) = $var(s28_customer_id);', `${bind}\n dlg_on_hangup("CRA_HANGUP");\n dlg_on_timeout("CRA_TIMEOUT");\n`); insert(' t_on_reply("S28_REPLY");', ' $dlg_val(cra_attempt)="1"; $avp(cra_attempt)="1";\n $var(cra_kind)="ATTEMPT"; $var(cra_code)=0; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n t_on_failure("CRA_FAILURE");\n'); insert('onreply_route[S28_REPLY] {', '\n if ($rs==180 || $rs==183 || $rs==100 || $rs==181 || $rs==182) {\n $var(cra_kind)="PROGRESS"; $var(cra_code)=$rs; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n }\n if ($rs>=200 && $rs<300) {\n $var(cra_kind)="ACCEPTED"; $var(cra_code)=$rs; $var(cra_source)="opensips"; route(CRA_EMIT_DLG);\n }\n', true); insert('route[S28_CDR_FAILURE] {', '\n $var(cra_kind)="END"; $var(cra_code)=$var(s28_reply_code); $var(cra_source)="platform-final"; route(CRA_EMIT_AVP);\n $var(cra_kind)="CLOSE"; $var(cra_source)="platform-closed"; route(CRA_EMIT_AVP);\n', true); const fields = ['uid', 'callid', 'customer', 'gateway', 'vendor', 'vendorGateway', 'caller', 'landing', 'callee', 'city', 'carrier', 'attempt']; function emitter(type, prefix) { const ref = k => `$${prefix}(cra_${k})`; const encoded = fields.map(k => `$(${prefix}(cra_${k}){s.b64encode})`).join('|'); return `\nroute[CRA_EMIT_${type}] {\n if (${ref('customer')}==NULL || ${ref('customer')}=="none") return;\n update_stat("cra_event_total", 1);\n xlog("L_NOTICE", "CRA1|$var(cra_kind)|$Ts|$Tsm|${ref('startsec')}|${ref('startmicro')}|$var(cra_code)|$var(cra_source)|${encoded}\\n");\n}\n`; } cfg += emitter('AVP', 'avp') + emitter('DLG', 'dlg_val') + ` route[CRA_HANGUP] { $var(cra_kind)="END"; $var(cra_code)=200; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG); $var(cra_kind)="CLOSE"; $var(cra_source)="dialog-closed"; route(CRA_EMIT_DLG); } route[CRA_TIMEOUT] { $var(cra_kind)="END"; $var(cra_code)=408; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG); $var(cra_kind)="CLOSE"; $var(cra_source)="dialog-timeout"; route(CRA_EMIT_DLG); } failure_route[CRA_FAILURE] { $var(cra_kind)="END"; $var(cra_code)=$T_reply_code; $var(cra_source)="verified-dialog-final"; route(CRA_EMIT_DLG); $var(cra_kind)="CLOSE"; $var(cra_source)="attempts-exhausted"; route(CRA_EMIT_DLG); } `; writeFileSync(output, versionMarker + cfg, 'utf8'); console.log('Analytics observation hooks generated; OpenSIPS config validation is required before activation.');