Class ::nsmcp::AccessAnalytics

::nsmcp::AccessAnalytics[i] create ... \
           [ -database:required database:required ] \
           [ -evictionLimit:integer (default "64") ] \
           [ -maxLineBytes:integer (default "65536") ] \
           [ -maxReadBytes:integer (default "16777216") ] \
           [ -maxRows:integer (default "2000000") ] \
           [ -maxSeconds:integer (default "2") ] \
           [ -mutex (default "") ] \
           [ -parser (default "::nsmcp::accessRecordParser") ] \
           [ -path:required path:required ] \
           [ -retentionSeconds:double (default "86400") ]

Defined in /usr/local/ns/tcl/nsmcp/access-analytics.tcl

Class Relations

  • class: ::nx::Class[i]
  • superclass: ::nsmcp::AccessLogIngestor[i]
  • subclass: ::nsmcp::UnifiedAccessAnalytics[i]
::nx::Class create ::nsmcp::AccessAnalytics \
     -superclass ::nsmcp::AccessLogIngestor

Methods (to be applied on instances)

  • correlateWarnings (scripted, public)

     <instance of nsmcp::AccessAnalytics[i]> correlateWarnings samples \
        redactor

    Correlate bounded warning examples with retained access records. Use the existing start-time index only; do not scan raw logs or trigger ingestion or historical backfill. Peer addresses are never selected.

    Parameters:
    samples (required)
    Up to three dictionaries with requestIdentifier and timestamp; optional system-log User-Agent fields take precedence over the cached agent.
    redactor (required)
    Redactor object applied before returning diagnostic evidence.
    Returns:
    Rooted JSON triples with sampled evidence and explicit missing and ambiguous counts.

    Testcases:
    No testcase defined.
    if {[llength $samples] > 3} {
        error "Too many warning correlation samples"
    }
    set fields {}
    set matched 0
    set missing 0
    set ambiguous 0
    set i 0
    set db [${:database} connect]
    try {
        foreach sample $samples {
            set request_id [dict get $sample requestIdentifier]
            set at [dict get $sample timestamp]
            if {![::nsmcp::logRecordParser validConnectionIdentifier $request_id]
                || ![string match "-conn:[ns_info server]:*" $request_id]
                || ![string is double -strict $at]} {
                incr missing
                continue
            }
            set lower [expr {$at-60}]
            set upper [expr {$at+1}]
            set rows [$db execute {SELECT request_id,start,path,pool,method,status,authenticationState,userAgent
                FROM nsmcp_access_requests WHERE start >= :lower AND start <= :upper
                AND request_id=:request_id LIMIT 2}]
            if {[llength $rows] == 0} {
                incr missing
                continue
            }
            if {[llength $rows] > 1} {
                incr ambiguous
                continue
            }
            set row [lindex $rows 0]
            set item {}
            set uaSource access-cache
            set authSource access-cache
            if {[dict exists $sample authenticationState] && [dict get $sample authenticationState] in {anonymous authenticated}} {
                dict set row authenticationState [dict get $sample authenticationState]
                set authSource system-log
            }
            set uaTruncated [expr {[string length [dict get $row userAgent]] >= 1024}]
            if {[dict exists $sample userAgent]} {
                dict set row userAgent [dict get $sample userAgent]
                set uaSource system-log
                set uaTruncated [dict get $sample userAgentMayBeTruncated]
            }
            foreach key {path pool method authenticationState userAgent} {
                lappend item $key string [$redactor message [dict get $row $key]]
            }
            lappend item requestIdentifier string $request_id start number [dict get $row start]  status number [dict get $row status] warningTimestamp number $at authenticationSource string $authSource  userAgentAvailable boolean [expr {[dict get $row userAgent] ne ""}]  userAgentSource string $uaSource userAgentMayBeTruncated boolean $uaTruncated
            lappend fields $i object $item
            incr i
            incr matched
        }
    } finally {
        $db close
    }
    return [list {} object [list available boolean true scope string sampled-warning-requests  requested number [llength $samples] matched number $matched missing number $missing ambiguous number $ambiguous  records array $fields limitations string {Bounded examples, not aggregate bot attribution. Exact connection identifier and start time within 60 seconds before to 1 second after the warning are required. Cache lag, eviction, long requests, legacy rows without User-Agent, and absent identifiers can prevent matches. No raw access-log scan or cache refresh was performed. Multiple candidates are not attributed. Pool and User-Agent alone do not prove bot or human identity. No peer addresses are returned.}]]
  • summary (scripted, public)

     <instance of nsmcp::AccessAnalytics[i]> summary options

    Summarize stored access traffic in one database snapshot. Compute coverage and aggregates together under the instance mutex, closing the session on every exit.

    Parameters:
    options (required)
    Tcl dictionary containing the operation options.
    Returns:
    The summary dictionary with aggregates and explicit coverage.

    Testcases:
    No testcase defined.
    # Use the scanner coverage and a single snapshot for all aggregates.
    # Hold a single SQLite snapshot for coverage and all aggregate queries.
    ns_mutex lock ${:mutex}
    try {
        set :db [${:database} connect]
        set :batchRows 0
        ${:db} execute {BEGIN}
        set options [:validateWindow $options]
        :peerSchema ${:db}
        set plain $options
        foreach key {path status pool peer authenticationState windowSeconds includePeerAddresses} {
            dict unset plain $key
        }
        # Coverage and counts share the same read transaction.
        set result [:summaryLocked $plain]
        set filters {}
        foreach key {path status pool peer authenticationState} {
            if {[dict exists $options $key]} {
                dict set filters $key [dict get $options $key]
            }
        }
        dict set result filters $filters
        set db ${:db}
        set from [dict get $result from]
        set to [dict get $result to]
        set where {start >= $from AND start < $to}
        foreach key {path status pool peer authenticationState} {
            if {[dict exists $options $key]} {
                set $key [dict get $options $key]
                append where " AND $key = \$$key"
            }
        }
        $db execute -arrayName row -script {
            foreach key $row(*) {
                dict set result $key $row($key)
            }
        } "SELECT count(*) AS requestCount,coalesce(sum(bytes),0) AS responseBytes,
            coalesce(sum(fallback),0) AS fallbackCount,coalesce(sum(excluded),0) AS excludedPaths,
            count(run) AS timingCount,coalesce(sum(accept),0) AS acceptTotal,
            coalesce(sum(queue),0) AS queueTotal,coalesce(sum(filter),0) AS filterTotal,
            coalesce(sum(run),0) AS runTotal,coalesce(max(run),0) AS runMax FROM nsmcp_access_requests WHERE $where"
        dict set result unavailableTimingCount [expr {[dict get $result requestCount]-[dict get $result timingCount]}]
        dict set result timingScope {Worker phase timings only; driver synthetic phases are unavailable, not measured zeros.}
        foreach {dimension column} {pools pool methods method statuses status paths path} {
            set counts {}
            # Bounded output, but count all matching rows in totals above.
            $db execute -arrayName row -script {
                if {$dimension eq "paths" && $row(label) eq ""} {
                    continue
                }
                dict set counts $row(label) $row(n)
            } "SELECT $column AS label,count(*) AS n FROM nsmcp_access_requests WHERE $where GROUP BY $column ORDER BY n DESC LIMIT 1000"
            dict set result $dimension $counts
        }
        set tracked 0
        dict for {label count} [dict get $result paths] {
            incr tracked $count
        }
        dict set result excludedPaths [expr {[dict get $result requestCount]-$tracked}]
        set statusTimings {}
        $db execute -arrayName row -script {
            lappend statusTimings [dict create status $row(status) requestCount $row(n) timingCount $row(timed) filterTotal $row(filters) runTotal $row(runs) runMax $row(maximum)]
        } "SELECT status,count(*) AS n,count(run) AS timed,coalesce(sum(filter),0) AS filters,
            coalesce(sum(run),0) AS runs,coalesce(max(run),0) AS maximum FROM nsmcp_access_requests WHERE $where GROUP BY status"
        dict set result statusTimings $statusTimings
        set authentication {}
        $db execute -arrayName row -script {
            dict set authentication $row(authenticationState) [dict create requestCount $row(n) status503Count $row(rejected) successfulCount $row(successful) successfulTimingCount $row(timed) successfulRunTotal $row(runs)]
        } "SELECT authenticationState,count(*) AS n,sum(status=503) AS rejected,
            sum(status BETWEEN 200 AND 299) AS successful,
            count(CASE WHEN status BETWEEN 200 AND 299 THEN run END) AS timed,
            coalesce(sum(CASE WHEN status BETWEEN 200 AND 299 THEN run END),0) AS runs
            FROM nsmcp_access_requests WHERE $where GROUP BY authenticationState"
        dict set result authenticationStates $authentication
        set unknownPeers [$db scalar "SELECT count(*) FROM nsmcp_access_requests WHERE $where AND peer = ''"]
        dict set result coverage unknownPeerCount $unknownPeers
        dict set result coverage peerCountsComplete [expr {$unknownPeers==0 && [dict get $result coverage windowComplete]}]
        if {[dict exists $options windowSeconds] && [dict get $options windowSeconds] > 0} {
            # RANGE counts all equal timestamps together and uses an inclusive
            # [end-windowSeconds,end] range clipped to the selected period.
            # SQLite performs the rolling calculation rather than Tcl fetching rows.
            set seconds [dict get $options windowSeconds]
            set burst [dict create windowSeconds $seconds requestCount 0 from {} to {}]
            $db execute -arrayName row -script {
                dict set burst requestCount $row(n)
                dict set burst from [expr {max(double($from),$row(start)-$seconds)}]
                dict set burst to $row(start)
                if {[dict exists $options includePeerAddresses] && [dict get $options includePeerAddresses]} {
                    dict set burst peerAddress $row(peer)
                }
            } "SELECT peer,start,n FROM (
                SELECT peer,start,count(*) OVER (
                    PARTITION BY peer ORDER BY start RANGE BETWEEN \$seconds PRECEDING AND CURRENT ROW) AS n
                FROM nsmcp_access_requests WHERE $where AND peer != ''
            ) ORDER BY n DESC,start,peer LIMIT 1"
            dict set result peerBurst $burst
        }
        set size [dict get $result bucketSeconds]
        set counts [dict get $result buckets]
        $db execute -arrayName row -script {
            dict set counts $row(slot) $row(n)
        } "SELECT cast(start/\$size AS INTEGER)*\$size AS slot,count(*) AS n FROM nsmcp_access_requests WHERE $where GROUP BY slot"
        dict set result buckets $counts
        dict set result pathCountsComplete [expr {[dict get $result excludedPaths]==0}]
        set retained [$db scalar {SELECT coalesce(min(start),'') FROM nsmcp_access_requests}]
        set through [$db scalar {SELECT value FROM nsmcp_access_metadata WHERE key='droppedThrough'}]
        set evictionAt [$db scalar {SELECT value FROM nsmcp_access_metadata WHERE key='lastEvictionAt'}]
        dict set result coverage source ${:path}
        dict set result coverage retainedRequestFrom $retained
        dict set result coverage droppedThrough $through
        dict set result coverage lastEvictionAt $evictionAt
        set evicted [expr {$through ne "" && $from <= $through}]
        dict set result coverage evictionOverlap $evicted
        if {$evicted} {
            dict set result coverage windowComplete false
            set reasons [dict get $result coverage incompleteReasons]
            lappend reasons eviction
            dict set result coverage incompleteReasons $reasons
        }
        dict set result coverage peerCountsComplete [expr {$unknownPeers==0 && [dict get $result coverage windowComplete]}]
        dict set result coverage observedPeerCountsComplete [expr {$unknownPeers==0}]
        set peerReasons {}
        if {$unknownPeers} {
            lappend peerReasons missing-peer-fields-in-cached-records
        }
        if {![dict get $result coverage windowComplete]} {
            lappend peerReasons window-coverage-incomplete
            foreach reason [dict get $result coverage incompleteReasons] {
                lappend peerReasons "window:$reason"
            }
        }
        dict set result coverage peerCountsIncompleteReasons $peerReasons
        dict set result coverage databaseBytes [file size [${:database} path]]
        dict set result coverage storageBudgetBytes [${:database} cget -maxBytes]
        set events {}
        $db execute -arrayName row -script {
            lappend events [dict create droppedAt $row(dropped_at) from $row(first_time) to $row(last_time) rowCount $row(rows) reason $row(reason)]
        } {SELECT * FROM nsmcp_access_evictions ORDER BY id DESC}
        dict set result evictions $events
        ${:db} execute {COMMIT}
        return $result
    } finally {
        if {[info exists :db]} {
            ${:db} close
            unset :db
        }
        unset -nocomplain :batchRows
        ns_mutex unlock ${:mutex}
    }
  • validateWindow (scripted, public)

     <instance of nsmcp::AccessAnalytics[i]> validateWindow options

    Validate summary windows and access-record filters. Keep supported filter options separate while the superclass validates time and bucket bounds.

    Parameters:
    options (required)
    Tcl dictionary containing the operation options.
    Returns:
    The normalized options dictionary, including retained filters.

    Testcases:
    No testcase defined.
    set extra {}
    foreach key {path status pool peer authenticationState windowSeconds includePeerAddresses} {
        if {![dict exists $options $key]} {
            continue
        }
        set value [dict get $options $key]
        if {$key eq "windowSeconds"} {
            if {![string is entier -strict $value] || $value<0 || $value>3600} {
                error "windowSeconds must be between 0 and 3600"
            }
        } elseif {$key eq "includePeerAddresses"} {
            if {![string is boolean -strict $value]} {
                error "includePeerAddresses must be boolean"
            }
        } elseif {$key eq "peer"} {
            if {![ns_ip valid $value]} {
                error "peer must be a valid IP address"
            }
        } elseif {$key eq "authenticationState"} {
            if {$value ni {anonymous authenticated unknown}} {
                error "Invalid authenticationState"
            }
        } elseif {$key eq "status"} {
            if {![string is entier -strict $value] || $value<100 || $value>599} {
                error "status must be between 100 and 599"
            }
        } elseif {$value eq "" || [string length $value]>512 || [string first \x00 $value]>=0} {
            error "invalid exact path or pool filter"
        }
        dict set extra $key $value
        dict unset options $key
    }
    return [dict merge [next [list $options]] $extra]