- Methods: All Methods Documented Methods Hide Methods
- Source: Display Source Hide Source
- Variables: Show Variables Hide Variables
Class ::nsmcp::CachedLogSearch
::nsmcp::CachedLogSearchcreate ... \
[ -cacheDirectory:required cacheDirectory:required ] \
[ -cacheKey cacheKey ] \
[ -maxCacheBytes:integer (default "67108864") ] \
[ -maxEvents:integer (default "1000") ] \
[ -maxMatchesPerCall:integer (default "1000") ] \
[ -maxMessageBytes:integer (default "65536") ] \
[ -maxReadBytes:integer (default "1048576") ] \
[ -maxSearches:integer (default "16") ] \
[ -mutex (default "") ] \
[ -path:required path:required ] \
[ -recordParser (default "::nsmcp::logRecordParser") ] \
[ -store:required store:required ]
Defined in /usr/local/ns/tcl/nsmcp/log-summary.tcl
Class Relations
::nx::Class create ::nsmcp::CachedLogSearch \ -superclass ::nsmcp::FileLogSourceMethods (to be applied on instances)
errorSummary (scripted, public)
<instance of nsmcp::CachedLogSearch> errorSummary options \ redactor
Aggregate errors from the configured system-log source. Apply bounded scanning and redaction, retaining resumable state when a scan slice cannot finish.
- Parameters:
- options (required)
- Tcl dictionary containing the operation options.
- redactor (required)
- Redactor object applied before returning diagnostic evidence.
- Returns:
- The error-summary result with continuation state when needed.
- Testcases:
- No testcase defined.
set from [dict get $options from] set to [dict get $options to] set maxGroups [dict get $options maxGroups] set literal [:dict getdef $options literal {}] set urlStats [::nsmcp::summaryUrlEmpty] set literalUncertain 0 set literalUnknownTimeUncertain 0 set severities [:dict getdef $options severities {Error Fatal Bug}] set selection [list $from $to $severities $literal] file stat ${:path} before set source [open ${:path} rb] set groups {} set matched 0 set ungrouped 0 set overflowSample {entries {} maximum 0} set overflowFamilySamples {} set sampleSeed {} set otherSources {} set otherUnknown 0 set otherOmitted 0 set familyTotals [::nsmcp::summaryFamilyCounts] set overflowFamilyTotals [::nsmcp::summaryFamilyCounts] set applicationMarkers 0 set applicationSuppressed 0 set categoryTotals {} set duplicateOrigins [::nsmcp::warningOriginEmpty] set parameterOrigins {} set omittedOriginParameterRecords 0 set previousByThread {} set repeatMarkers 0 set reportedSuppressed 0 set attributedMarkers 0 set unattributedMarkers 0 set crossWindowMarkers 0 set repeatExamples {} set unknownTime 0 set unrecognized 0 set examples {} set timestampedRecords 0 set earliestAt {} set latestAt {} set firstEntryAt {} set lastEntryAt {} set firstEntryOffset {} set lastEntryOffset {} set cachedStamp {} set cachedSeconds {} set postgresRecords 0 set postgresExamples {} set previousTimestamp {} set pendingPostgres {} set mutexTraces 0 set mutexObservations 0 set mutexUngroupedObservations 0 set mutexUngroupedTotal 0.0 set mutexGroups {} set mutexUngrouped 0 set shortEntries 0 set untimestamped 0 set untimestampedExamples {} set severityTotals {} set unknownTimeTotals {} foreach severity $severities { dict set severityTotals $severity 0 dict set unknownTimeTotals $severity 0 } set pending false set clipped 0 set firstTime {} set lastTime {} set deadline [expr {[clock milliseconds]+1000}] set start 0 set examined 0 set stable true set prefix {} if {[dict exists $options scanState]} { set state [dict get $options scanState] if {([dict exists $state selection] && [dict get $state selection] ne $selection) || ($literal ne {} && ![dict exists $state urlStats])} { close $source error "system-log literal summary changed during scan; start a new summary" } foreach name {urlStats literalUncertain literalUnknownTimeUncertain groups matched ungrouped overflowSample overflowFamilySamples sampleSeed otherSources otherUnknown otherOmitted familyTotals overflowFamilyTotals applicationMarkers applicationSuppressed categoryTotals duplicateOrigins parameterOrigins omittedOriginParameterRecords previousByThread repeatMarkers reportedSuppressed attributedMarkers unattributedMarkers crossWindowMarkers repeatExamples unknownTime unrecognized examples severityTotals unknownTimeTotals pending clipped firstTime lastTime start examined prefix mutexTraces mutexObservations mutexUngroupedObservations mutexUngroupedTotal mutexGroups mutexUngrouped shortEntries untimestamped untimestampedExamples postgresRecords postgresExamples previousTimestamp pendingPostgres timestampedRecords earliestAt latestAt firstEntryAt lastEntryAt firstEntryOffset lastEntryOffset cachedStamp cachedSeconds} { if {[dict exists $state $name]} { set $name [dict get $state $name] } } if {$matched > 0 && (![dict exists $state familyTotals] || ![dict exists $state otherSources])} { close $source error "system-log family summary changed during scan; start a new summary" } if {![dict exists $state mutexObservations] && ($examined>0 || $mutexTraces>0)} { close $source error "system-log mutex accounting changed during scan; start a new summary" } if {![dict exists $state mutexGroups]} { set mutexUngrouped $mutexTraces } if {[list $before(dev) $before(ino)] ne [dict get $state identity] || $before(size) < [dict get $state sourceBytes]} { close $source error "system log changed during summary scan; start a new summary" } set before(size) [dict get $state sourceBytes] } if {$sampleSeed eq ""} { set sampleSeed [list $before(dev) $before(ino) $before(size) $from $to $severities] } set chunkEnd [expr {min($before(size),$examined+4194304)}] set reader {} try { set currentPrefix [read $source [expr {min(256,$before(size))}]] if {$prefix ne "" && $prefix ne $currentPrefix} { error "system log changed during summary scan; start a new summary" } set prefix $currentPrefix seek $source $examined set reader [::nsmcp::LogSummaryReader new -channel $source -limit $before(size) -offset $examined -maxBytes ${:maxMessageBytes}] while {$examined < $chunkEnd && [clock milliseconds] < $deadline} { set offset $examined set event [$reader next] set examined [dict get $event endOffset] if {$examined <= $offset} { break } if {![dict get $event complete]} { set pending true } # Neighboring timestamp strings are contextual estimates, never dates # assigned to the diagnostic or used for time-window error totals. if {[regexp {^\[([0-9]{2}/[A-Za-z]{3}/[0-9]{4}:[^\]\r\n]+)\]\[[^\]]+\]\[[^\]]+\]} [dict get $event message] -> stamp]} { if {$stamp ne $cachedStamp} { set cachedStamp $stamp set cachedSeconds [${:recordParser} timestampSeconds $stamp] } if {$cachedSeconds ne ""} { incr timestampedRecords if {$firstEntryAt eq ""} { set firstEntryAt $cachedSeconds set firstEntryOffset $offset } set lastEntryAt $cachedSeconds set lastEntryOffset $offset if {$earliestAt eq "" || $cachedSeconds<$earliestAt} { set earliestAt $cachedSeconds } if {$latestAt eq "" || $cachedSeconds>$latestAt} { set latestAt $cachedSeconds } } foreach i $pendingPostgres { set fields [lindex $postgresExamples $i] lappend fields followingTimestamp string $stamp lset postgresExamples $i $fields } set pendingPostgres {} set previousTimestamp $stamp } # Most records are Notice/Debug output. Avoid parser dispatch and # metadata allocation for valid headers outside the counted levels. if {[regexp {^\[[^\]\r\n]+\]\[[^\]\r\n]+\]\[[^\]\r\n]+\][ \t]+([A-Za-z][A-Za-z0-9_()-]*):[ \t]*} [dict get $event message] -> level] && $level ni $severities} { continue } set meta [${:recordParser} metadata [dict get $event message]] set recordType [dict get $meta recordType] if {$recordType eq "postgres"} { incr postgresRecords if {[llength $postgresExamples]<5} { set text [$redactor message [dict get $event message]] lappend pendingPostgres [llength $postgresExamples] lappend postgresExamples [list offset number $offset endOffset number $examined severity string [dict get $meta severity] example string [string range $text 0 511] truncated boolean [expr {[dict get $event truncated] || [string length $text]>512}] timeSource string inferred-neighbors precedingTimestamp string $previousTimestamp] } } if {$recordType eq "mutex-trace"} { # Scheduler condition waits are expected implementation details, # not contention evidence. Exclude only this exact hold trace; # ns:sched lock waits and negative-wait warnings remain visible. if {[dict get $meta mutexName] eq "ns:sched" && [dict get $meta mutexOperation] eq "unlock" && [dict get $meta mutexMetric] eq "hold"} { continue } incr mutexTraces set mutexKey [list [dict get $meta mutexName] [dict get $meta mutexOperation] [dict get $meta mutexMetric]] set duration [dict get $meta mutexDurationSeconds] set deferred [expr {[dict exists $meta mutexDeferred] && [dict get $meta mutexDeferred]}] set observations [expr {$deferred ? [dict get $meta mutexObservationCount] : 1}] set total [expr {$deferred ? [dict get $meta mutexTotalSeconds] : $duration}] incr mutexObservations $observations if {[dict exists $mutexGroups $mutexKey]} { set group [dict get $mutexGroups $mutexKey] dict incr group count dict incr group observations $observations if {$deferred} { dict incr group deferredRecords dict incr group deferredObservations $observations } if {$deferred && $observations>1} { dict set group minimumKnown false } dict set group total [expr {[dict get $group total]+$total}] dict set group min [expr {min([dict get $group min],$duration)}] dict set group lastOffset $offset if {$duration > [dict get $group max]} { dict set group max $duration dict set group maxOffset $offset dict set group maxThread [$redactor message [dict get $meta logThread]] } if {$duration<0} { dict incr group negative } dict set mutexGroups $mutexKey $group } elseif {[dict size $mutexGroups]<128} { dict set mutexGroups $mutexKey [dict create count 1 observations $observations total $total min $duration max $duration deferredRecords [expr {$deferred ? 1 : 0}] deferredObservations [expr {$deferred ? $observations : 0}] minimumKnown [expr {!$deferred || $observations==1}] firstOffset $offset lastOffset $offset maxOffset $offset maxThread [$redactor message [dict get $meta logThread]] negative [expr {$duration<0}]] } else { incr mutexUngrouped incr mutexUngroupedObservations $observations set mutexUngroupedTotal [expr {$mutexUngroupedTotal+$total}] } } if {$recordType eq "system-short"} { incr shortEntries } if {$recordType in {mutex-trace system-short postgres}} { incr untimestamped if {[llength $untimestampedExamples]<5} { set text [$redactor message [dict get $event message]] set fields [list offset number $offset endOffset number $examined recordType string $recordType logThread string [dict get $meta logThread] severity string [dict get $meta severity] example string [string range $text 0 511] truncated boolean [expr {[dict get $event truncated] || [string length $text]>512}]] foreach key {mutexName mutexOperation mutexMetric} { if {[dict exists $meta $key]} { lappend fields $key string [$redactor message [dict get $meta $key]] } } foreach key {mutexDurationSeconds mutexObservationCount mutexTotalSeconds} { if {[dict exists $meta $key]} { lappend fields $key number [dict get $meta $key] } } if {[dict exists $meta mutexDeferred]} { lappend fields mutexDeferred boolean [dict get $meta mutexDeferred] } lappend untimestampedExamples $fields } } if {$recordType eq "mutex-trace"} { continue } set severity [dict get $meta severity] if {$severity eq ""} { incr unrecognized if {[llength $examples] < 5} { lappend examples [list offset number $offset endOffset number $examined example string [string range [$redactor message [dict get $event message]] 0 511] truncated boolean [expr {[dict get $event truncated] || [string length [dict get $event message]]>512}]] } continue } if {$severity ni $severities} { continue } set literalMatched [expr {$literal eq {} || [string first $literal [dict get $event message]]>=0}] set timestamp [dict get $meta timestamp] if {$timestamp eq $cachedStamp} { set seconds $cachedSeconds } else { set seconds [${:recordParser} timestampSeconds $timestamp] } if {$seconds eq "" || ![string is double -strict $seconds]} { if {!$literalMatched} { if {[dict get $event truncated]} { incr literalUnknownTimeUncertain } continue } if {$severity in {Error Fatal Bug}} { incr unknownTime } dict incr unknownTimeTotals $severity continue } set text [$redactor message [dict get $event message]] set normalized [::nsmcp::summaryHeading $text] set category [dict get $normalized category] set threadKey {} regexp {^\[[^\]]+\]\[([^\]]+)\]} [dict get $event message] -> threadKey set prior {} if {$threadKey ne "" && [dict exists $previousByThread $threadKey]} { set prior [dict get $previousByThread $threadKey] } if {$category ne "log-repeat-marker" && $threadKey ne "" && ([dict exists $previousByThread $threadKey] || [dict size $previousByThread] < 2048)} { dict set previousByThread $threadKey [dict create timestamp $seconds severity $severity offset $offset pattern [dict get $normalized pattern]] } if {$seconds < $from || $seconds >= $to} { continue } if {!$literalMatched} { if {[dict get $event truncated]} { incr literalUncertain } continue } incr matched if {$literal ne {}} { ::nsmcp::summaryUrlAdd urlStats [dict get $event message] $seconds [dict get $meta requestIdentifier] $redactor [dict get $event truncated] } dict incr severityTotals $severity if {$firstTime eq "" || $seconds < $firstTime} { set firstTime $seconds } if {$lastTime eq "" || $seconds > $lastTime} { set lastTime $seconds } dict incr categoryTotals $category set family [::nsmcp::summaryFamily $normalized] dict incr familyTotals $family if {$family eq "other"} { # Require an emitter-style identifier followed by a colon; # free-form headings remain explicitly unattributed. set heading [dict get $normalized pattern] if {[regexp {^([A-Za-z_][A-Za-z0-9_:.-]{0,79}):[ \t]} $heading -> emitter]} { if {[dict exists $otherSources $emitter] || [dict size $otherSources] < 32} { dict incr otherSources $emitter } else { incr otherOmitted } } else { incr otherUnknown } } if {[dict get $normalized suppressedOccurrences] > 0} { incr applicationMarkers incr applicationSuppressed [dict get $normalized suppressedOccurrences] } if {$category eq "log-repeat-marker"} { incr repeatMarkers # Native sameLineCount includes the originally written message. scan [dict get $normalized repeats] %lld runCount set suppressed [expr {max(0,$runCount-1)}] incr reportedSuppressed $suppressed set attributed [expr {$prior ne "" && [dict get $prior severity] eq $severity}] if {$attributed} { incr attributedMarkers if {[dict get $prior timestamp] < $from || [dict get $prior timestamp] >= $to} { incr crossWindowMarkers } } else { incr unattributedMarkers } if {[llength $repeatExamples] < 5} { set fields [list markerOffset number $offset markerTimestamp number $seconds reportedSuppressedOccurrences number $suppressed attributed boolean $attributed] if {$attributed} { lappend fields precedingOffset number [dict get $prior offset] precedingTimestamp number [dict get $prior timestamp] precedingPattern string [dict get $prior pattern] } lappend repeatExamples $fields } } if {$category eq "duplicate-parameter"} { set requestMetadata [::nsmcp::warningRequestMetadata [dict get $event message] $redactor] ::nsmcp::warningOriginAdd duplicateOrigins $requestMetadata 128 set parameter [dict get $normalized parameter] if {[dict exists $parameterOrigins $parameter] || [dict size $parameterOrigins] < 128} { set origins [expr {[dict exists $parameterOrigins $parameter] ? [dict get $parameterOrigins $parameter] : [::nsmcp::warningOriginEmpty]}] ::nsmcp::warningOriginAdd origins $requestMetadata 32 dict set parameterOrigins $parameter $origins } else { incr omittedOriginParameterRecords } } set pattern [dict get $normalized pattern] set key [list $severity $pattern] if {![dict exists $groups $key]} { if {[dict size $groups] >= 512} { incr ungrouped dict incr overflowFamilyTotals $family set priority [zlib crc32 [list $sampleSeed $offset]] set sample [:dict getdef $overflowFamilySamples $family {entries {} maximum 0}] if {[llength [dict get $overflowSample entries]] < 5 || $priority < [dict get $overflowSample maximum] || [llength [dict get $sample entries]] < 3 || $priority < [dict get $sample maximum]} { set fields [list offset number $offset endOffset number $examined timestamp number $seconds severity string $severity category string $category family string $family pattern string $pattern example string [string range $text 0 511] truncated boolean [expr {[dict get $event truncated] || [string length $text] > 512}]] ::nsmcp::summaryOverflowSample overflowSample 5 $priority $offset $fields ::nsmcp::summaryOverflowSample sample 3 $priority $offset $fields dict set overflowFamilySamples $family $sample } continue } dict set groups $key [dict create severity $severity pattern $pattern category $category parameter {} samples {} parameterCounts {} omittedParameterRecords 0 schedulerStats {} suppressedSuffixRecords 0 reportedSuppressedOccurrences 0 count 0 firstAt $seconds lastAt $seconds example [string range $text 0 2047] requestIdentifier [dict get $meta requestIdentifier] exampleTruncated false] } set group [dict get $groups $key] dict incr group count if {[dict get $normalized suppressedOccurrences] > 0} { dict incr group suppressedSuffixRecords dict incr group reportedSuppressedOccurrences [dict get $normalized suppressedOccurrences] } if {$category eq "duplicate-parameter"} { set parameter [dict get $normalized parameter] set counts [dict get $group parameterCounts] if {[dict exists $counts $parameter] || [dict size $counts] < 128} { dict incr counts $parameter dict set group parameterCounts $counts } else { dict incr group omittedParameterRecords } } if {$category eq "scheduler-lateness"} { dict set group scheduler [dict get $normalized scheduler] dict for {name value} [dict get $normalized timings] { if {[dict exists $group schedulerStats $name]} { set metric [dict get $group schedulerStats $name] dict incr metric count dict set metric total [expr {[dict get $metric total] + $value}] dict set metric min [expr {min([dict get $metric min],$value)}] dict set metric max [expr {max([dict get $metric max],$value)}] } else { set metric [dict create count 1 total $value min $value max $value] } dict set group schedulerStats $name $metric } } if {$category eq "duplicate-parameter" && [llength [dict get $group samples]] < 12 && [::nsmcp::logRecordParser validConnectionIdentifier [dict get $meta requestIdentifier]]} { set retainedForParameter 0 foreach retained [dict get $group samples] { if {[dict exists $retained parameter] && [dict get $retained parameter] eq $parameter} { incr retainedForParameter } } set sample [dict merge [dict create parameter $parameter requestIdentifier [dict get $meta requestIdentifier] timestamp $seconds] $requestMetadata] if {$retainedForParameter < 3 && $sample ni [dict get $group samples]} { dict lappend group samples $sample } } dict set group firstAt [expr {min([dict get $group firstAt],$seconds)}] dict set group lastAt [expr {max([dict get $group lastAt],$seconds)}] if {[dict get $event truncated] || [string length $text]>2048} { dict set group exampleTruncated true incr clipped } dict set groups $key $group } file stat ${:path} after seek $source 0 set stable [expr {[list $before(dev) $before(ino)] eq [list $after(dev) $after(ino)] && $after(size)>=$before(size) && [read $source [string length $prefix]] eq $prefix}] } finally { if {$reader ne ""} { $reader destroy } close $source } if {!$stable} { error "system log changed during summary scan; start a new summary" } set state {} if {$examined < $before(size)} { foreach name {urlStats literalUncertain literalUnknownTimeUncertain groups matched ungrouped overflowSample overflowFamilySamples sampleSeed otherSources otherUnknown otherOmitted familyTotals overflowFamilyTotals applicationMarkers applicationSuppressed categoryTotals duplicateOrigins parameterOrigins omittedOriginParameterRecords previousByThread repeatMarkers reportedSuppressed attributedMarkers unattributedMarkers crossWindowMarkers repeatExamples unknownTime unrecognized examples severityTotals unknownTimeTotals pending clipped firstTime lastTime start examined prefix mutexTraces mutexObservations mutexUngroupedObservations mutexUngroupedTotal mutexGroups mutexUngrouped shortEntries untimestamped untimestampedExamples postgresRecords postgresExamples previousTimestamp pendingPostgres timestampedRecords earliestAt latestAt firstEntryAt lastEntryAt firstEntryOffset lastEntryOffset cachedStamp cachedSeconds} { dict set state $name [set $name] } dict set state selection $selection dict set state identity [list $before(dev) $before(ino)] dict set state sourceBytes $before(size) } set exampleEntries {} set i 0 foreach example $examples { lappend exampleEntries $i object $example incr i } set untimestampedEntries {} set i 0 foreach example $untimestampedExamples { lappend untimestampedEntries $i object $example incr i } set mutexEntries {} set i 0 dict for {key group} $mutexGroups { lassign $key mutex operation metric lappend mutexEntries $i object [list mutexName string [$redactor message $mutex] operation string $operation metric string $metric count number [dict get $group count] recordCount number [dict get $group count] observationCount number [dict get $group observations] deferredRecords number [dict get $group deferredRecords] deferredObservations number [dict get $group deferredObservations] totalSeconds number [dict get $group total] minimumKnown boolean [dict get $group minimumKnown] minSeconds [expr {[dict get $group minimumKnown] ? "number" : "null"}] [expr {[dict get $group minimumKnown] ? [dict get $group min] : {}}] maxSeconds number [dict get $group max] meanSeconds number [expr {[dict get $group total]/double([dict get $group observations])}] negativeDurations number [dict get $group negative] firstOffset number [dict get $group firstOffset] lastOffset number [dict get $group lastOffset] maxOffset number [dict get $group maxOffset] maxLogThread string [dict get $group maxThread]] incr i } set postgresEntries {} set i 0 foreach example $postgresExamples { if {[lsearch -exact $example followingTimestamp]<0} { lappend example followingTimestamp string {} } lappend postgresEntries $i object $example incr i } set severityFields {} dict for {severity count} $severityTotals { lappend severityFields $severity number $count } set selectedFields {} set i 0 foreach severity $severities { lappend selectedFields $i string $severity incr i } set unknownFields {} set unknownSelected 0 dict for {severity count} $unknownTimeTotals { lappend unknownFields $severity number $count incr unknownSelected $count } set ordered {} dict for {key group} $groups { lappend ordered [list [dict get $group count] $group] } set entries {} set index 0 set omittedGroups 0 set omittedGroupRecords 0 set returnedGroupRecords 0 foreach pair [lsort -integer -decreasing -index 0 $ordered] { set group [lindex $pair 1] if {$index >= $maxGroups} { incr omittedGroups incr omittedGroupRecords [dict get $group count] continue } set fields {} foreach name {severity pattern category parameter example requestIdentifier} { lappend fields $name string [dict get $group $name] } foreach name {count firstAt lastAt suppressedSuffixRecords reportedSuppressedOccurrences} { lappend fields $name number [dict get $group $name] } if {[dict get $group category] eq "duplicate-parameter"} { set parameters {} dict for {parameter count} [dict get $group parameterCounts] { lappend parameters $parameter number $count } lappend fields parameterCounts object $parameters parameterCapacity number 128 omittedParameterRecords number [dict get $group omittedParameterRecords] parameterBreakdownComplete boolean [expr {[dict get $group omittedParameterRecords] == 0}] } if {[dict get $group category] eq "scheduler-lateness"} { set scheduler [dict get $group scheduler] set details [list jobId number [dict get $scheduler jobId] flags string [dict get $scheduler flags] intervalSeconds number [dict get $scheduler intervalSeconds]] set timings {} dict for {name metric} [dict get $group schedulerStats] { lappend timings $name object [list count number [dict get $metric count] minSeconds number [dict get $metric min] maxSeconds number [dict get $metric max] meanSeconds number [expr {[dict get $metric total]/double([dict get $metric count])}]] } lappend details timings object $timings lappend fields scheduler object $details } set samples {} set sampleIndex 0 foreach sample [dict get $group samples] { set sampleFields [list requestIdentifier string [dict get $sample requestIdentifier] timestamp number [dict get $sample timestamp]] if {[dict exists $sample parameter]} { lappend sampleFields parameter string [dict get $sample parameter] } foreach name {authenticationState authenticationSource} { if {[dict exists $sample $name]} { lappend sampleFields $name string [dict get $sample $name] } } if {[dict exists $sample userAgent]} { lappend sampleFields userAgent string [dict get $sample userAgent] userAgentSource string system-log userAgentMayBeTruncated boolean [dict get $sample userAgentMayBeTruncated] } lappend samples $sampleIndex object $sampleFields incr sampleIndex } lappend fields requestSamples array $samples exampleTruncated boolean [dict get $group exampleTruncated] incr returnedGroupRecords [dict get $group count] lappend entries $index object $fields incr index } # Observed entry times describe only bytes scanned so far, not rotation or # proof of complete coverage. File mtime is deliberately not used as a date. set bounds [list source string observed-timestamped-entries scope string scanned-file timestampedRecords number $timestampedRecords] foreach name {earliestAt latestAt firstEntryAt lastEntryAt firstEntryOffset lastEntryOffset} { set value [set $name] lappend bounds $name [expr {$value eq "" ? "null" : "number"}] $value } set logTimezone :localtime if {[info exists ::env(TZ)] && $::env(TZ) ne ""} { set logTimezone $::env(TZ) } # System-log rotations may be scheduled by application code or external # tools. nslog's logrollhour describes access logs, not this source. set rotation [list scheduleKnown boolean false schedule null {} lastVerifiedAt null {} timezone string $logTimezone onSignal boolean [ns_config -bool ns/parameters logrollonsignal [ns_config -bool ns/parameters logroll true]] filenameFormat string [ns_config ns/parameters logrollfmt {}] configurationScope string process-system-log limitations string {Signal handling and filename format are configuration, not proof of rotation. A daily schedule and last successful rotation time are not exposed by this interface; application or external rotation is possible.}] set categories {} dict for {category count} $categoryTotals { lappend categories $category number $count } set repeatFields {} set repeatIndex 0 foreach example $repeatExamples { lappend repeatFields $repeatIndex object $example incr repeatIndex } set familyFields {} set overflowFamilyFields {} set familySamples {} set familyIndex 0 dict for {family count} $familyTotals { lappend familyFields $family number $count } dict for {family count} $overflowFamilyTotals { lappend overflowFamilyFields $family number $count if {$count == 0} { continue } set examples [::nsmcp::summaryOverflowExamples [dict get $overflowFamilySamples $family]] set rows {} set n 0 foreach example $examples { lappend rows $n object $example incr n } lappend familySamples $familyIndex object [list family string $family count number $count exampleLimit number 3 examples array $rows omittedExampleRecords number [expr {$count-[llength $examples]}]] incr familyIndex } set overflowExamples [::nsmcp::summaryOverflowExamples $overflowSample] set overflowFields {} set overflowIndex 0 foreach example $overflowExamples { lappend overflowFields $overflowIndex object $example incr overflowIndex } set dedup [list applicationMarkerRecords number $applicationMarkers applicationReportedSuppressedOccurrences number $applicationSuppressed configuredNow boolean [ns_config -bool ns/parameters logdeduplicate false] markerRecords number $repeatMarkers messageRecords number [expr {$matched-$repeatMarkers}] reportedSuppressedOccurrences number $reportedSuppressed attributedMarkers number $attributedMarkers unattributedMarkers number $unattributedMarkers crossWindowMarkers number $crossWindowMarkers examples array $repeatFields limitations string {Severity totals count physical records including repeat markers, not expanded occurrences. Markers inherit the preceding message severity. Native sameLineCount includes the originally written message; reported suppressed occurrences subtract one per marker. A marker timestamp does not locate suppressed occurrences in time or identify their requests. Attribution uses a bounded per-process/thread map over selected severities; rotation, map limits and missing prior entries prevent attribution. Current configuration is not historical configuration. Recognized trailing application/core suppression suffixes are counted separately in applicationMarkerRecords and applicationReportedSuppressedOccurrences, never added to severity totals or native reportedSuppressedOccurrences. Their suppressed event times and severity history are not established.}] set parameterFields {} set parameterIndex 0 dict for {parameter origins} $parameterOrigins { lappend parameterFields $parameterIndex object [concat [list parameter string $parameter] [::nsmcp::warningOriginFields $origins 32]] incr parameterIndex } set originFields [list scope string selected-window-duplicate-records overall object [::nsmcp::warningOriginFields $duplicateOrigins 128] parameters array $parameterFields parameterCapacity number 128 omittedParameterRecords number $omittedOriginParameterRecords parameterBreakdownComplete boolean [expr {$omittedOriginParameterRecords == 0}] limitations string {Aggregates include all selected timestamped duplicate-parameter records in the scanned window before group/evidence limits, including grouping overflow. Counts are warning records, not distinct requests or users. Authentication is derived from the explicit OpenACS user_id diagnostic before redaction (0 anonymous, positive decimal authenticated, missing/invalid unknown); actual user IDs are never retained. User-Agents are decoded and redacted before grouping and may merge after redaction or truncation. Missing, empty or invalid User-Agents are unknown. Cardinality-overflow records, retained ranking omissions and truncated prefixes are separate. userAgentAggregationComplete requires no unknown, truncated or cardinality-overflow records, independently of output limits. userAgentRankingComplete additionally requires no withheld ranked records. userAgentCountsComplete is the compatibility alias for ranking completeness. Ranking omissions do not discard aggregated records. Rankings are not global rankings when omittedUserAgentRecords is nonzero. Self-reported User-Agents and pool membership do not verify bot/human identity. No access cache or peer addresses are required; file/window coverage limitations still apply.}] set otherFields {} dict for {emitter count} $otherSources { lappend otherFields $emitter number $count } set otherBreakdown [list recordCount number [dict get $familyTotals other] sourceCounts object $otherFields sourceCapacity number 32 unknownSourceRecords number $otherUnknown omittedSourceRecords number $otherOmitted sourceCountsComplete boolean [expr {$otherUnknown == 0 && $otherOmitted == 0}] limitations string {Counts cover all selected other-family records before detailed grouping limits. Sources are bounded emitter-style heading prefixes, not verified subsystems or causes. Unknown source records lack a recognized prefix; omitted source records exceed the 32-source cardinality bound. Retained counts plus unknown and omitted equal recordCount. All retained source counts are returned; source text excludes message payloads.}] set urlFields [::nsmcp::summaryUrlFields $urlStats $maxGroups [expr {$examined==$before(size)}] $literalUncertain $redactor] lappend urlFields enabled boolean [expr {$literal ne {}}] set literalFields [list enabled boolean [expr {$literal ne {}}] caseSensitive boolean true unknownMatchWindowRecords number $literalUncertain unknownMatchUnknownTimeRecords number $literalUnknownTimeUncertain complete boolean [expr {$examined==$before(size) && $literalUncertain==0}] scope string {Literal matching uses the retained logical record, including continuation lines, after ANSI removal and before redaction. A literal absent from a clipped record is not a confirmed nonmatch; unknownMatchWindowRecords reports those records separately. This is not a unique-request count or a retention guarantee.}] set result [list {} object [list server string [ns_info server] scope string process literalSelection object $literalFields urlAggregation object $urlFields from number $from to number $to timezone string [dict get $options timezone] timestampTimezone string [clock format [clock seconds] -format %Z] selectedSeverities array $selectedFields severityTotals object $severityFields matchedRecords number $matched ungroupedRecords number $ungrouped groupingComplete boolean [expr {$ungrouped == 0}] groupCapacity number 512 familyTotals object $familyFields otherBreakdown object $otherBreakdown overflowFamilyTotals object $overflowFamilyFields overflowSamplesByFamily array $familySamples overflowFamilyExampleLimit number 3 overflowSamplingMethod string deterministic-crc32-priority overflowExamples array $overflowFields overflowExampleLimit number 5 overflowExamplesOmittedRecords number [expr {max(0,$ungrouped-[llength $overflowExamples])}] returnedGroupCount number $index returnedGroupRecords number $returnedGroupRecords omittedGroups number $omittedGroups omittedGroupRecords number $omittedGroupRecords categoryTotals object $categories duplicateParameterOrigins object $originFields deduplication object $dedup groups array $entries grouping string {Known API procedure, missing-resource fallback, host-validation and HTTP request-buffer templates normalize variable names, paths and payloads while preserving redacted examples. otherBreakdown counts bounded emitter prefixes across all other-family records, including overflow, with unknown and cardinality-omitted counts. Known duplicate-parameter warnings share one group per severity with up to 128 parameterCounts and explicit omittedParameterRecords. Scheduler lateness groups retain job ID, flags and interval; varying durations are aggregated separately. Recognized suppression suffixes do not fragment keys. familyTotals classify every selected record into a fixed broad message family (other is explicit), independently of detailed-group capacity. overflowFamilyTotals are exact counts of excess records by family. These are physical record classifications, not verified causes or incidents. overflowExamples retain up to five redacted hash-priority samples across the examined overflow stream; overflowSamplesByFamily retains up to three per fixed family, with explicit omittedExampleRecords. Sampling uses deterministic CRC-32 priorities from fixed snapshot identity, bounds and byte offsets, retains state across scan continuation, and returns samples in byte order. It is not cryptographic or a guaranteed statistically unbiased sample; per-family examples are stratified, not proportional frequency evidence. Use exact family counts for distribution claims. Missing examples do not mean absent records. Up to 512 distinct groups are counted independently of maxGroups; the largest counted groups are returned. omittedGroupRecords are classified but not returned; ungroupedRecords exceeded internal capacity. Rankings are globally complete only when groupingComplete=true. Unknown message keys use redacted first lines with numeric tokens and hex addresses normalized.} coverage object [list logBounds object $bounds rotation object $rotation sourceBytes number $before(size) examinedFromOffset number $start examinedToOffset number $examined sourceFullyExamined boolean [expr {$stable && $start==0 && $examined==$before(size)}] sourceChanged boolean [expr {!$stable}] historyUnreadBytes number $start unreadBytes number [expr {max(0,$before(size)-$examined)}] unknownTimeErrors number $unknownTime unknownTimeRecords number $unknownSelected unknownTimeSeverityTotals object $unknownFields unrecognizedRecords number $unrecognized unrecognizedExamples array $exampleEntries mutexTraceRecords number $mutexTraces mutexTraceObservations number $mutexObservations mutexTraceGroups array $mutexEntries mutexTraceUngroupedObservations number $mutexUngroupedObservations mutexTraceUngroupedTotalSeconds number $mutexUngroupedTotal mutexTraceUngroupedRecords number $mutexUngrouped shortLogRecords number $shortEntries untimestampedRecords number $untimestamped untimestampedExamples array $untimestampedEntries postgresRecords number $postgresRecords postgresExamples array $postgresEntries diagnosticScope string scanned-file clippedExamples number $clipped pendingFinalRecord boolean $pending rotationsExamined boolean false windowComplete boolean false limitations string {Only the configured current system log is examined. Rotated files and time before this file are not certified. Counts include observed final records, whose continuations may still grow. Expected ns:sched unlock/hold diagnostics are omitted entirely. Mutex count and recordCount count physical diagnostic records; observationCount counts represented intervals. Deferred condition-wait summaries contribute their reported N and total T, never N times their maximum. meanSeconds is totalSeconds/observationCount. Minimum is null when a multi-interval summary prevents establishing it; maximum/thread attribution remain known. These threshold-selected observations are not lifetime ns_info locks statistics, and different scan/snapshot times or trace-enable periods prevent direct reconciliation. Other mutex traces are grouped by mutex name, operation and wait/hold metric over every scanned trace (up to 128 groups; excess records are counted in mutexTraceUngroupedRecords). Durations are individual observations, not additive wall time or proof of contention. Negative durations indicate timing diagnostics, not valid waits. Mutex traces and short system-log entries have no timestamps; diagnostic counters and examples cover the scanned file, not necessarily the requested time window. PostgreSQL DETAIL, HINT and CONTEXT fields are grouped with their diagnostic. postgresExamples provides explicitly inferred neighboring timestamp bounds, which may be missing or out of order under concurrent logging; these are contextual estimates, not guaranteed event times or request attribution. No timestamps are inherited from adjacent entries. Untimestamped selected severities contribute to unknownTimeRecords and unknownTimeSeverityTotals, not window severityTotals; unknownTimeErrors retains only Error/Fatal/Bug.}]]] if {$state ne ""} { lappend result $state } return $resultflush (scripted, public)
<instance of nsmcp::CachedLogSearch> flush
Discard cached search state for this log source. Serialize the update and clean up retained result resources owned by the source.
- Returns:
- The cache cleanup result.
- Testcases:
- No testcase defined.
ns_mutex lock ${:mutex} try { set key ${:cacheKey}:search if {[nsv_exists ${:store} $key]} { set meta [nsv_get ${:store} $key] :removeQueries [dict get $meta queries] dict set meta queries {} dict incr meta generation nsv_set ${:store} $key $meta } } finally { ns_mutex unlock ${:mutex} }results (scripted, public)
<instance of nsmcp::CachedLogSearch> results handle [ cursor ] \ [ limit ]
Retrieve a bounded page from an unexpired cached search. Validate the handle and page bounds under the mutex and retain coverage and truncation metadata.
- Parameters:
- handle (required)
- Retained job or conversation handle; ownership and expiry checks still apply.
- cursor (optional, defaults to
"0")- Zero-based cursor into retained results. Defaults to '0'.
- limit (optional, defaults to
"20")- Maximum number of records or entries to return. Defaults to '20'.
- Returns:
- The cached page dictionary.
- Testcases:
- No testcase defined.
if {![regexp {^[0-9a-f]{64}$} $handle]} { error "invalid result handle" } if {![string is entier -strict $cursor] || $cursor < 0 || ![string is integer -strict $limit] || $limit < 1 || $limit > 100} { error "expected a nonnegative cursor and a limit between 1 and 100" } ns_mutex lock ${:mutex} try { set key ${:cacheKey}:search set query {} if {[nsv_exists ${:store} $key]} { set meta [nsv_get ${:store} $key] dict for {q candidate} [dict get $meta queries] { if {[dict exists $candidate resultHandle] && [dict get $candidate resultHandle] eq $handle} { set query $candidate break } } } if {$query eq ""} { return -code error -errorcode {NSMCP RESULT UNKNOWN} "unknown or expired result handle" } # Result handles created before a live reload have no cap flag. set query [dict merge {limitReached false} $query] file stat ${:path} current set probe [open ${:path} rb] try { set prefix [read $probe [string length [dict get $meta prefix]]] } finally { close $probe } if {[list $current(dev) $current(ino)] ne [dict get $meta identity] || $current(size) < [dict get $meta observedSize] || $prefix ne [dict get $meta prefix]} { error "result handle expired after log rotation; repeat the search" } set end [dict get $query fileBytes] if {$cursor > $end} { error "cursor is beyond the published results" } set f [open [dict get $query resultFile] rb] try { if {$cursor > 0} { seek $f [expr {$cursor-1}] if {[read $f 1] ne "\n"} { error "cursor must be a record boundary" } } seek $f $cursor set records {} set bytes 0 while {[tell $f] < $end && [llength $records] < $limit} { set start [tell $f] set line {} while {1} { set position [tell $f] set chunk [read $f [expr {min(65536,$end-$position,2097153-[string length $line])}]] set newline [string first \n $chunk] if {$newline >= 0} { append line [string range $chunk 0 [expr {$newline-1}]] seek $f [expr {$position+$newline+1}] break } append line $chunk if {[string length $line] >= 2097153} { error "cached record exceeds the page byte limit" } if {$chunk eq "" || [tell $f] >= $end} { error "incomplete result cache" } } set length [expr {[tell $f]-$start}] if {$length > 2097152} { error "cached record exceeds the page byte limit" } if {$bytes+$length > 2097152} { seek $f $start break } incr bytes $length lappend records [ns_json parse [encoding convertfrom utf-8 $line]] } set next [tell $f] } finally { close $f } set more [expr {$next < $end}] return [dict create resultHandle $handle records $records nextCursor [expr {$more ? $next : ""}] more $more resultCount [dict get $query resultCount] scanIncomplete [dict get $query scanIncomplete] pending [dict get $query pending] cacheFull [dict get $query cacheFull] limitReached [dict get $query limitReached] generation [dict get $meta generation]] } finally { ns_mutex unlock ${:mutex} }search (scripted, public)
<instance of nsmcp::CachedLogSearch> search literal [ severity ] \ [ correlationField ] [ maxResults ]
Search raw records for literal text or exact request identifiers. Use one literal key for ordinary searches or a bounded identifier batch for the systemMany/accessMany correlation modes. Retain partial-scan and result-limit evidence.
- Parameters:
- literal (required)
- Literal search text; an empty value disables this additional text filter.
- severity (optional)
- System-log severity name to match.
- correlationField (optional)
- Exact header-field correlation mode, including systemMany/accessMany batches.
- maxResults (optional, defaults to
"0")- Retained record cap; zero keeps the legacy slice behavior. Defaults to '0'.
- Returns:
- The cached snapshot with explicit partial-scan and result-limit flags.
- Testcases:
- No testcase defined.
if {![string is integer -strict $maxResults] || $maxResults < 0 || $maxResults > 100} { error "result limit must be 0..100" } set many [expr {$correlationField in {systemMany accessMany}}] if {$many} { if {[llength $literal] < 1 || [llength $literal] > 20} { error "expected 1..20 request identifiers" } set literal [lsort -unique $literal] foreach id $literal { set check [expr {$correlationField eq "accessMany" ? "validConnectionIdentifier" : "validRequestIdentifier"}] if {[string length [encoding convertto utf-8 $id]] > 512 || ![${:recordParser} $check $id]} { error "correlation requires complete request identifiers" } } } if {$correlationField ni {{} system access systemMany accessMany}} { error "expected an empty, system or access correlation field" } set identifierCheck [expr {$correlationField eq "access" ? "validConnectionIdentifier" : "validRequestIdentifier"}] if {!$many && $correlationField ne "" && ![${:recordParser} $identifierCheck $literal]} { error "correlation requires a complete request identifier" } if {$severity ne "" && ![regexp {^[A-Za-z][A-Za-z0-9_()-]*$} $severity]} { error "expected a log severity name" } if {!$many && ($literal eq "" || [string length [encoding convertto utf-8 $literal]] > 4096 || [string first \n $literal] >= 0 || [string first \r $literal] >= 0 || [string first \x00 $literal] >= 0)} { error "expected a nonempty, single-line literal search key of at most 4096 bytes" } ns_mutex lock ${:mutex} try { set metaKey ${:cacheKey}:search set queryKey [ns_crypto::md string -digest sha256 -- [list log-metadata-v8 $literal $severity $correlationField $maxResults]] file stat ${:path} before set source [open ${:path} rb] set scanner [open ${:path} rb] set bounded {} try { fconfigure $source -translation binary -encoding binary fconfigure $scanner -translation binary -encoding binary file stat ${:path} opened set identity [list $before(dev) $before(ino)] if {$identity ne [list $opened(dev) $opened(ino)]} { error "log rotated while opening; retry" } set keys [expr {$many ? $literal : [list $literal]}] set bounded [::nsmcp::BoundedLogScanner new -channel $scanner -limit $before(size) -literals $keys -cacheDirectory ${:cacheDirectory}] set reset false set generation 1 if {[nsv_exists ${:store} $metaKey]} { set meta [nsv_get ${:store} $metaKey] set generation [dict get $meta generation] set prefix [dict get $meta prefix] if {$identity ne [dict get $meta identity] || $before(size) < [dict get $meta observedSize] || [read $source [string length $prefix]] ne $prefix} { :removeQueries [dict get $meta queries] set reset true incr generation } } else { set reset true } if {$reset} { seek $source 0 set meta [dict create identity $identity generation $generation observedSize $before(size) queries {} prefix [read $source [expr {min(256,$before(size))}]]] } if {[dict get $meta prefix] eq "" && $before(size) > 0} { seek $source 0 dict set meta prefix [read $source [expr {min(256,$before(size))}]] } set queries [dict get $meta queries] if {![dict exists $queries $queryKey]} { if {[dict size $queries] >= ${:maxSearches}} { set oldest {} set oldestTime {} dict for {key query} $queries { if {$oldest eq "" || [dict get $query used] < $oldestTime} { set oldest $key set oldestTime [dict get $query used] } } :removeQueries [dict create $oldest [dict get $queries $oldest]] dict unset queries $oldest } dict set queries $queryKey [dict create resultFile [:temporaryFile] pendingFile [:temporaryFile] resultHandle [ns_crypto::randombytes -encoding hex 32] cursor 0 resultCount 0 fileBytes 0 pending false used [clock clicks -milliseconds] cacheFull false limitReached false sourceBytes -1 scanIncomplete true] } set query [dict get $queries $queryKey] # Publish ownership before processing, so an interrupted scan's # files remain discoverable for invalidation/eviction. dict set meta queries $queries nsv_set ${:store} $metaKey $meta if {[dict get $query limitReached] || ([dict get $query sourceBytes] == $before(size) && ![dict get $query scanIncomplete])} { dict set query used [clock clicks -milliseconds] dict set meta queries $queryKey $query dict set meta observedSize $before(size) nsv_set ${:store} $metaKey $meta return [dict merge $query [dict create generation $generation reset $reset]] } set output [open [dict get $query resultFile] r+] set pending [open [dict get $query pendingFile] w] try { fconfigure $output -encoding utf-8 -translation lf fconfigure $pending -encoding utf-8 -translation lf # Discard writes beyond the last published checkpoint after # a failed scan, rather than appending duplicate results. ns_ftruncate $output [dict get $query fileBytes] seek $output 0 end dict set query pending false set cursor [dict get $query cursor] set matched 0 set byteBudgetReached false set scanEnd [expr {$many ? min($before(size),$cursor+8388608) : $before(size)}] while {$cursor < $scanEnd && $matched < ${:maxMatchesPerCall} && ![dict get $query cacheFull] && ($maxResults == 0 || [dict get $query resultCount] < $maxResults)} { # Shared bounded candidate search; record framing and exact # metadata checks below retain their format-specific behavior. set found [$bounded find $cursor $scanEnd] if {$found < 0 && $many && $scanEnd < $before(size)} { set overlap 0 foreach id $literal { set overlap [expr {max($overlap,[string length $id]-1)}] } set cursor [expr {$scanEnd-$overlap}] set byteBudgetReached true break } if {$found < 0 || $found >= $before(size)} { set cursor [:recordStart $source [expr {$before(size)-1}] $before(size)] break } set start [:recordStart $source $found $before(size)] set event [:record $source $start $before(size)] incr matched set metadata [${:recordParser} metadata [dict get $event message]] set accepted [expr {$severity eq "" || [dict get $metadata severity] eq $severity}] if {$correlationField eq "system"} { set accepted [expr {$accepted && [dict get $metadata requestIdentifier] eq $literal}] } elseif {$correlationField eq "access"} { set accepted [expr {$accepted && [${:recordParser} accessRequestIdentifier [dict get $event message]] eq $literal}] } elseif {$correlationField eq "systemMany"} { set accepted [expr {$accepted && [dict get $metadata requestIdentifier] in $literal}] } elseif {$correlationField eq "accessMany"} { set accepted [expr {$accepted && [${:recordParser} accessRequestIdentifier [dict get $event message]] in $literal}] } if {!$accepted} { if {![dict get $event complete]} { set cursor $start break } set cursor [dict get $event endOffset] continue } set json [:encodeEvent $event] if {![dict get $event complete]} { puts $pending $json dict set query pending true set cursor $start break } set bytes [string length [encoding convertto utf-8 $json\n]] if {[tell $output]+$bytes > ${:maxCacheBytes}} { dict set query cacheFull true set cursor $start break } puts $output $json dict incr query resultCount set cursor [dict get $event endOffset] } flush $output dict set query fileBytes [tell $output] dict set query cursor $cursor dict set query used [clock clicks -milliseconds] } finally { close $output close $pending } file stat ${:path} after seek $source 0 set prefix [dict get $meta prefix] if {$identity ne [list $after(dev) $after(ino)] || $after(size) < $before(size) || [read $source [string length $prefix]] ne $prefix} { error "log changed during scan; retry" } # cursor may point to the pending final message. Distinguish that # deliberate recheck from a match-budget backlog. if {$many && $cursor >= $scanEnd && $scanEnd < $before(size)} { set byteBudgetReached true } dict set query limitReached [expr {$maxResults > 0 && [dict get $query resultCount] >= $maxResults}] dict set query scanIncomplete [expr {$matched >= ${:maxMatchesPerCall} || [dict get $query cacheFull] || [dict get $query limitReached] || $byteBudgetReached}] dict set query sourceBytes $before(size) dict set meta observedSize $before(size) dict set meta queries $queryKey $query nsv_set ${:store} $metaKey $meta return [dict merge $query [dict create generation $generation reset $reset]] } finally { if {$bounded ne ""} { $bounded destroy } close $source close $scanner } } finally { ns_mutex unlock ${:mutex} }
- Methods: All Methods Documented Methods Hide Methods
- Source: Display Source Hide Source
- Variables: Show Variables Hide Variables
![[i]](/resources/acs-subsite/ZoomIn16.gif)