From 6998186d95161d9e25449ccab282488c6954f498 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:34:53 +0200 Subject: [PATCH] Classify observability errors --- ...OpenTelemetryObservabilityBackend.class.st | 33 ++++--- .../MCPExternalTestTool.class.st | 6 ++ .../MCPHaltingTestTool.class.st | 6 ++ .../MCPLocalObservabilityBackendTest.class.st | 63 +++++++++++++ src/MCP/MCP.class.st | 12 ++- src/MCP/MCPLocalObservabilityBackend.class.st | 7 +- src/MCP/MCPObservabilityBackend.class.st | 91 +++++++++++++++++++ src/MCP/MCPTool.class.st | 6 ++ src/MCP/MCPToolCallRecord.class.st | 70 ++++++++++++++ src/MCP/MCPToolCallStats.class.st | 37 +++++++- 10 files changed, 306 insertions(+), 25 deletions(-) diff --git a/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st b/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st index bef3725..d1195a8 100644 --- a/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st +++ b/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st @@ -119,25 +119,30 @@ MCPOpenTelemetryObservabilityBackend >> enrichSpan: span withRecord: aRecord att attributeAt: 'mcp.output.tokens.approx' put: aRecord approximateOutputTokenCount; attributeAt: 'mcp.output_budget.exceeded' put: aRecord outputBudgetExceeded. aRecord outputBudgetExceeded ifTrue: [ span addEventNamed: 'mcp.output_budget_exceeded' attributes: attributes ]. - anError ifNotNil: [ - span - attributeAt: 'error.type' put: anError class name asString; - setStatus: OTSpan error description: anError description; - recordException: anError ]. + aRecord errorKind ifNotEmpty: [ :kind | span attributeAt: 'mcp.error.kind' put: kind ]. + aRecord errorCode ifNotEmpty: [ :code | span attributeAt: 'mcp.error.code' put: code ]. + aRecord errorClass ifNotEmpty: [ :className | span attributeAt: 'error.type' put: className ]. + aRecord causeClass ifNotEmpty: [ :className | span attributeAt: 'mcp.error.cause_class' put: className ]. + aRecord schemaViolationCount > 0 ifTrue: [ + span attributeAt: 'mcp.error.schema.violation_count' put: aRecord schemaViolationCount ]. + aRecord status = 'error' ifTrue: [ + span setStatus: OTSpan error description: aRecord displaySummary. + anError ifNotNil: [ span recordException: anError ] ]. aRecord status = 'ok' ifTrue: [ span setStatus: OTSpan ok description: '' ] ] { #category : 'private - metrics' } MCPOpenTelemetryObservabilityBackend >> errorAttributesForRecord: aRecord baseAttributes: attributes [ - ^ attributes copyWith: 'error.type' -> aRecord errorClass -] - -{ #category : 'private' } -MCPOpenTelemetryObservabilityBackend >> errorClassNameFor: anError [ - - anError ifNil: [ ^ '' ]. - ^ anError class name asString + | errorAttributes | + errorAttributes := OrderedCollection withAll: attributes. + aRecord errorClass ifNotEmpty: [ :className | errorAttributes add: 'error.type' -> className ]. + aRecord errorKind ifNotEmpty: [ :kind | errorAttributes add: 'mcp.error.kind' -> kind ]. + aRecord errorCode ifNotEmpty: [ :code | errorAttributes add: 'mcp.error.code' -> code ]. + aRecord causeClass ifNotEmpty: [ :className | errorAttributes add: 'mcp.error.cause_class' -> className ]. + aRecord schemaViolationCount > 0 ifTrue: [ + errorAttributes add: 'mcp.error.schema.violation_count' -> aRecord schemaViolationCount ]. + ^ errorAttributes asArray ] { #category : 'private - instruments' } @@ -155,7 +160,7 @@ MCPOpenTelemetryObservabilityBackend >> finishRecord: aRecord result: aToolResul duration := (finishedAt - aRecord startedAt) asMilliSeconds. statusString := self statusForResult: aToolResult error: anError. aRecord finishAt: finishedAt durationMilliseconds: duration status: statusString. - aRecord errorClass: (self errorClassNameFor: anError). + self recordErrorDetailsFor: aRecord result: aToolResult error: anError. self recordOutputBudgetFor: aRecord result: aToolResult error: anError. aRecord inputObject: nil. self updateEventKindsForRecord: aRecord diff --git a/src/MCP-Tests-Resources/MCPExternalTestTool.class.st b/src/MCP-Tests-Resources/MCPExternalTestTool.class.st index 6eb0124..aaaca4c 100644 --- a/src/MCP-Tests-Resources/MCPExternalTestTool.class.st +++ b/src/MCP-Tests-Resources/MCPExternalTestTool.class.st @@ -50,6 +50,12 @@ MCPExternalTestTool >> executeWithRequest: aRequest [ ^ self successResultText: 'External test tool executed.' data: Dictionary new ] +{ #category : 'testing' } +MCPExternalTestTool >> shouldRecordObservability [ + + ^ false +] + { #category : 'metadata' } MCPExternalTestTool >> title [ diff --git a/src/MCP-Tests-Resources/MCPHaltingTestTool.class.st b/src/MCP-Tests-Resources/MCPHaltingTestTool.class.st index 9a0c42f..51d85d2 100644 --- a/src/MCP-Tests-Resources/MCPHaltingTestTool.class.st +++ b/src/MCP-Tests-Resources/MCPHaltingTestTool.class.st @@ -56,6 +56,12 @@ MCPHaltingTestTool >> groupName [ ^ 'plugins' ] +{ #category : 'testing' } +MCPHaltingTestTool >> shouldRecordObservability [ + + ^ false +] + { #category : 'metadata' } MCPHaltingTestTool >> title [ diff --git a/src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st b/src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st index 1fba040..abe7a36 100644 --- a/src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st +++ b/src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st @@ -152,6 +152,21 @@ MCPLocalObservabilityBackendTest >> testMCPRecordsToolCallsWhenObservabilityIsEn self assert: mcp observability metricsFileReference exists ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] ] +{ #category : 'tests' } +MCPLocalObservabilityBackendTest >> testMCPTestToolsDoNotRecordObservability [ + + | mcp result | + mcp := MCP new. + mcp observabilityEnabled: true. + + result := mcp executeTool: MCPExternalTestTool new named: MCPExternalTestTool toolName withParams: Dictionary new. + + self deny: (result at: #isError ifAbsent: [ false ]). + self assert: mcp observability totalCallCount equals: 0. + self assert: mcp observabilityMetrics isEmpty. + self assert: mcp observability recentCallRecords isEmpty +] + { #category : 'tests' } MCPLocalObservabilityBackendTest >> testOutlierExplanationUsesPriorAverage [ @@ -224,6 +239,54 @@ MCPLocalObservabilityBackendTest >> testRecordsOutputBudgetMetadata [ self assert: (statsData at: #lastCallAt) isNotEmpty ] +{ #category : 'tests' } +MCPLocalObservabilityBackendTest >> testRecordsSchemaViolationErrorDetails [ + + | error record state stats | + state := MCPLocalObservabilityBackend new. + state enable. + error := MCPInvalidToolInput new + toolName: 'class_search'; + violations: { (MCPInvalidToolInput missingRequiredArgumentViolationNamed: 'name') }; + messageText: 'Tool input does not match the input schema.'; + yourself. + record := state recordToolCallStart: 'class_search'. + state recordToolCallFinish: record result: nil error: error. + stats := state metrics first. + + self assert: record errorKind equals: 'input_schema'. + self assert: record errorClass equals: 'MCPInvalidToolInput'. + self assert: record schemaViolationCount equals: 1. + self assert: (record schemaViolationKeywords includes: 'required'). + self assert: (stats errorCountsByKind at: 'input_schema') equals: 1. + self assert: (stats errorCountsByClass at: 'MCPInvalidToolInput') equals: 1 +] + +{ #category : 'tests' } +MCPLocalObservabilityBackendTest >> testRecordsStructuredToolErrorDetails [ + + | details record result state stats | + state := MCPLocalObservabilityBackend new. + state enable. + details := { + (#errorClass -> 'MCPCommandError'). + (#errorCode -> 'MethodNotFound'). + (#causeClass -> 'KeyNotFound'). + (#message -> 'No method found.') } asDictionary. + record := state recordToolCallStart: 'method_get'. + result := MCPToolGetMethod new errorResultText: 'No method found.' details: details. + state recordToolCallFinish: record result: result error: nil. + stats := state metrics first. + + self assert: record status equals: 'error'. + self assert: record errorKind equals: 'domain'. + self assert: record errorClass equals: 'MCPCommandError'. + self assert: record errorCode equals: 'MethodNotFound'. + self assert: record causeClass equals: 'KeyNotFound'. + self assert: (stats errorCountsByKind at: 'domain') equals: 1. + self assert: (stats errorCountsByCode at: 'MethodNotFound') equals: 1 +] + { #category : 'tests' } MCPLocalObservabilityBackendTest >> testRecordsSuccessfulCallMetricsAndLogs [ diff --git a/src/MCP/MCP.class.st b/src/MCP/MCP.class.st index 9060350..7241df3 100644 --- a/src/MCP/MCP.class.st +++ b/src/MCP/MCP.class.st @@ -147,11 +147,13 @@ MCP >> executeTool: tool named: toolName withParams: arguments [ MCP >> executeTool: tool named: toolName withParams: arguments dispatchPath: dispatchPath [ | observabilityRecord request result toolResult | - observabilityRecord := self observability - recordToolCallStart: toolName - input: arguments - dispatchPath: dispatchPath - context: Dictionary new. + observabilityRecord := nil. + tool shouldRecordObservability ifTrue: [ + observabilityRecord := self observability + recordToolCallStart: toolName + input: arguments + dispatchPath: dispatchPath + context: Dictionary new ]. ^ [ request := tool requestFromToolCallArguments: arguments. result := tool executeWithRequest: request. diff --git a/src/MCP/MCPLocalObservabilityBackend.class.st b/src/MCP/MCPLocalObservabilityBackend.class.st index 4c800d0..190c797 100644 --- a/src/MCP/MCPLocalObservabilityBackend.class.st +++ b/src/MCP/MCPLocalObservabilityBackend.class.st @@ -583,7 +583,7 @@ MCPLocalObservabilityBackend >> recordToolCallFinish: aRecord result: aToolResul stats := self statsForToolNamed: aRecord toolName. explanation := self outlierExplanationForDurationMilliseconds: duration stats: stats. aRecord finishAt: finishTimestamp durationMilliseconds: duration status: statusString. - aRecord errorClass: (self errorClassNameFor: anError). + self recordErrorDetailsFor: aRecord result: aToolResult error: anError. aRecord summary: (self summaryForResult: aToolResult). aRecord outlierExplanation: explanation. self recordOutputBudgetFor: aRecord result: aToolResult error: anError. @@ -763,6 +763,11 @@ MCPLocalObservabilityBackend >> traceRecordDictionaryFor: aRecord [ dictionary at: #durationMilliseconds put: aRecord durationMilliseconds. dictionary at: #status put: aRecord status. aRecord errorClass ifNotEmpty: [ :errorClass | dictionary at: #errorClass put: errorClass ]. + aRecord errorKind ifNotEmpty: [ :errorKind | dictionary at: #errorKind put: errorKind ]. + aRecord errorCode ifNotEmpty: [ :errorCode | dictionary at: #errorCode put: errorCode ]. + aRecord causeClass ifNotEmpty: [ :causeClass | dictionary at: #causeClass put: causeClass ]. + aRecord schemaViolationKeywords ifNotEmpty: [ :keywords | dictionary at: #schemaViolationKeywords put: keywords ]. + aRecord schemaViolationCount > 0 ifTrue: [ dictionary at: #schemaViolationCount put: aRecord schemaViolationCount ]. aRecord inputByteCount > 0 ifTrue: [ dictionary at: #inputBytes put: aRecord inputByteCount ]. aRecord outputByteCount > 0 ifTrue: [ dictionary at: #outputBytes put: aRecord outputByteCount. diff --git a/src/MCP/MCPObservabilityBackend.class.st b/src/MCP/MCPObservabilityBackend.class.st index 70bb55f..08ab198 100644 --- a/src/MCP/MCPObservabilityBackend.class.st +++ b/src/MCP/MCPObservabilityBackend.class.st @@ -47,6 +47,44 @@ MCPObservabilityBackend >> enabled: aBoolean [ self subclassResponsibility ] +{ #category : 'private - errors' } +MCPObservabilityBackend >> errorDetailsForResult: aToolResult error: anError [ + + | details resultDetails | + details := Dictionary new. + anError ifNotNil: [ + details at: #errorClass put: anError class name asString. + details at: #message put: (anError messageText ifNil: [ anError asString ]). + anError mcpAddToolErrorDetailsTo: details ]. + resultDetails := self errorDetailsFromToolResult: aToolResult. + resultDetails keysAndValuesDo: [ :key :value | details at: key asSymbol put: value ]. + ^ details +] + +{ #category : 'private - errors' } +MCPObservabilityBackend >> errorDetailsFromToolResult: aToolResult [ + + | errorDetails structuredContent | + aToolResult ifNil: [ ^ Dictionary new ]. + structuredContent := aToolResult structuredContent. + structuredContent ifNil: [ ^ Dictionary new ]. + structuredContent isDictionary ifFalse: [ ^ Dictionary new ]. + errorDetails := self valueAtKey: #error in: structuredContent ifAbsent: [ nil ]. + errorDetails isDictionary ifFalse: [ ^ Dictionary new ]. + ^ errorDetails +] + +{ #category : 'private - errors' } +MCPObservabilityBackend >> errorKindForDetails: details error: anError violations: violations [ + + violations ifNotEmpty: [ ^ 'input_schema' ]. + (self stringValueAtKey: #errorClass in: details) = 'MCPInvalidToolInput' ifTrue: [ ^ 'input_schema' ]. + (self stringValueAtKey: #errorCode in: details) ifNotEmpty: [ ^ 'domain' ]. + anError ifNil: [ ^ 'tool_result' ]. + anError mcpIsJRPCError ifTrue: [ ^ 'json_rpc' ]. + ^ 'exception' +] + { #category : 'accessing' } MCPObservabilityBackend >> exportDirectory [ @@ -101,6 +139,21 @@ MCPObservabilityBackend >> recentCallRecords [ ^ #( ) ] +{ #category : 'private - errors' } +MCPObservabilityBackend >> recordErrorDetailsFor: aRecord result: aToolResult error: anError [ + + | details violations | + aRecord status = 'error' ifFalse: [ ^ self ]. + details := self errorDetailsForResult: aToolResult error: anError. + violations := self violationsFromDetails: details. + aRecord errorClass: (self stringValueAtKey: #errorClass in: details). + aRecord errorCode: (self stringValueAtKey: #errorCode in: details). + aRecord causeClass: (self stringValueAtKey: #causeClass in: details). + aRecord schemaViolationKeywords: (self violationKeywordsFor: violations). + aRecord schemaViolationCount: violations size. + aRecord errorKind: (self errorKindForDetails: details error: anError violations: violations) +] + { #category : 'recording' } MCPObservabilityBackend >> recordSessionEndFor: anMCP [ @@ -165,6 +218,15 @@ MCPObservabilityBackend >> statusText [ self subclassResponsibility ] +{ #category : 'private - errors' } +MCPObservabilityBackend >> stringValueAtKey: aKey in: aDictionary [ + + | value | + value := self valueAtKey: aKey in: aDictionary ifAbsent: [ '' ]. + value ifNil: [ ^ '' ]. + ^ value asString +] + { #category : 'accessing' } MCPObservabilityBackend >> totalCallCount [ @@ -188,3 +250,32 @@ MCPObservabilityBackend >> traceRecords [ ^ #( ) ] + +{ #category : 'private - errors' } +MCPObservabilityBackend >> valueAtKey: aKey in: aDictionary ifAbsent: absentBlock [ + + (aDictionary includesKey: aKey) ifTrue: [ ^ aDictionary at: aKey ]. + ^ aDictionary at: aKey asString ifAbsent: absentBlock +] + +{ #category : 'private - errors' } +MCPObservabilityBackend >> violationKeywordFor: aViolation [ + + aViolation isDictionary ifTrue: [ ^ self stringValueAtKey: #keyword in: aViolation ]. + ^ '' +] + +{ #category : 'private - errors' } +MCPObservabilityBackend >> violationKeywordsFor: violations [ + + ^ ((violations collect: [ :each | self violationKeywordFor: each ]) reject: [ :each | each isEmpty ]) asSet asArray +] + +{ #category : 'private - errors' } +MCPObservabilityBackend >> violationsFromDetails: details [ + + | violations | + violations := self valueAtKey: #violations in: details ifAbsent: [ #( ) ]. + violations ifNil: [ ^ #( ) ]. + ^ violations asArray +] diff --git a/src/MCP/MCPTool.class.st b/src/MCP/MCPTool.class.st index 346fa6b..057b355 100644 --- a/src/MCP/MCPTool.class.st +++ b/src/MCP/MCPTool.class.st @@ -533,6 +533,12 @@ MCPTool >> shouldAdvertiseStringArrayItemDescription: anItemDescription [ ^ true ] +{ #category : 'testing' } +MCPTool >> shouldRecordObservability [ + + ^ true +] + { #category : 'testing' } MCPTool >> shouldSaveImageAfterSuccessfulExecution [ diff --git a/src/MCP/MCPToolCallRecord.class.st b/src/MCP/MCPToolCallRecord.class.st index ad9bbe6..787085d 100644 --- a/src/MCP/MCPToolCallRecord.class.st +++ b/src/MCP/MCPToolCallRecord.class.st @@ -12,6 +12,11 @@ Class { 'durationMilliseconds', 'status', 'errorClass', + 'errorKind', + 'errorCode', + 'causeClass', + 'schemaViolationKeywords', + 'schemaViolationCount', 'summary', 'outlierExplanation', 'inputSnapshot', @@ -65,6 +70,11 @@ MCPToolCallRecord >> asDictionary [ (#durationMilliseconds -> self durationMilliseconds). (#status -> self status). (#errorClass -> self errorClass). + (#errorKind -> self errorKind). + (#errorCode -> self errorCode). + (#causeClass -> self causeClass). + (#schemaViolationKeywords -> self schemaViolationKeywords). + (#schemaViolationCount -> self schemaViolationCount). (#summary -> self summary). (#eventKinds -> self eventKinds). (#outlierExplanation -> self outlierExplanation). @@ -78,6 +88,18 @@ MCPToolCallRecord >> asDictionary [ (#output -> self outputSnapshot) } asDictionary ] +{ #category : 'accessing' } +MCPToolCallRecord >> causeClass [ + + ^ causeClass ifNil: [ '' ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> causeClass: aString [ + + causeClass := aString +] + { #category : 'accessing' } MCPToolCallRecord >> dispatchPath [ @@ -136,6 +158,30 @@ MCPToolCallRecord >> errorClass: aString [ errorClass := aString ] +{ #category : 'accessing' } +MCPToolCallRecord >> errorCode [ + + ^ errorCode ifNil: [ '' ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> errorCode: aString [ + + errorCode := aString +] + +{ #category : 'accessing' } +MCPToolCallRecord >> errorKind [ + + ^ errorKind ifNil: [ '' ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> errorKind: aString [ + + errorKind := aString +] + { #category : 'accessing' } MCPToolCallRecord >> eventKinds [ @@ -310,6 +356,30 @@ MCPToolCallRecord >> recordOutputBudget: budgetData [ self outputBudgetExplanation: (budgetData at: #outputBudgetExplanation) ] +{ #category : 'accessing' } +MCPToolCallRecord >> schemaViolationCount [ + + ^ schemaViolationCount ifNil: [ 0 ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> schemaViolationCount: anInteger [ + + schemaViolationCount := anInteger +] + +{ #category : 'accessing' } +MCPToolCallRecord >> schemaViolationKeywords [ + + ^ schemaViolationKeywords ifNil: [ #( ) ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> schemaViolationKeywords: aCollection [ + + schemaViolationKeywords := aCollection ifNil: [ #( ) ] ifNotNil: [ aCollection asArray ] +] + { #category : 'accessing' } MCPToolCallRecord >> startedAt [ diff --git a/src/MCP/MCPToolCallStats.class.st b/src/MCP/MCPToolCallStats.class.st index b52e4af..a4a2857 100644 --- a/src/MCP/MCPToolCallStats.class.st +++ b/src/MCP/MCPToolCallStats.class.st @@ -22,7 +22,9 @@ Class { 'lastOutputBytes', 'lastApproximateOutputTokens', 'outputBudgetExceededCount', - 'errorCountsByClass' + 'errorCountsByClass', + 'errorCountsByKind', + 'errorCountsByCode' ], #category : 'MCP-Observability', #package : 'MCP', @@ -46,6 +48,8 @@ MCPToolCallStats >> asDictionary [ (#successCount -> self successCount). (#errorCount -> self errorCount). (#errorCountsByClass -> self errorCountsByClass copy). + (#errorCountsByKind -> self errorCountsByKind copy). + (#errorCountsByCode -> self errorCountsByCode copy). (#averageMilliseconds -> self averageMilliseconds asFloat). (#minMilliseconds -> (self minMilliseconds ifNil: [ 0 ])). (#maxMilliseconds -> (self maxMilliseconds ifNil: [ 0 ])). @@ -81,7 +85,9 @@ MCPToolCallStats >> asMetricsDictionary [ (#lastApproximateOutputTokens -> self lastApproximateOutputTokens) } asDictionary. self errorCount positive ifTrue: [ dictionary at: #errorCount put: self errorCount. - dictionary at: #errorCountsByClass put: self errorCountsByClass copy ]. + dictionary at: #errorCountsByClass put: self errorCountsByClass copy. + dictionary at: #errorCountsByKind put: self errorCountsByKind copy. + dictionary at: #errorCountsByCode put: self errorCountsByCode copy ]. self outputBudgetExceededCount positive ifTrue: [ dictionary at: #outputBudgetExceededCount put: self outputBudgetExceededCount ]. self lastOutlierExplanation ifNotNil: [ :explanation | explanation isEmpty ifFalse: [ dictionary at: #lastOutlierExplanation put: explanation ] ]. @@ -174,6 +180,18 @@ MCPToolCallStats >> errorCountsByClass [ ^ errorCountsByClass ifNil: [ errorCountsByClass := Dictionary new ] ] +{ #category : 'accessing' } +MCPToolCallStats >> errorCountsByCode [ + + ^ errorCountsByCode ifNil: [ errorCountsByCode := Dictionary new ] +] + +{ #category : 'accessing' } +MCPToolCallStats >> errorCountsByKind [ + + ^ errorCountsByKind ifNil: [ errorCountsByKind := Dictionary new ] +] + { #category : 'accessing' } MCPToolCallStats >> errorRate [ @@ -197,7 +215,9 @@ MCPToolCallStats >> initialize [ totalMilliseconds := 0. totalOutputBytes := 0. outputBudgetExceededCount := 0. - errorCountsByClass := Dictionary new + errorCountsByClass := Dictionary new. + errorCountsByKind := Dictionary new. + errorCountsByCode := Dictionary new ] { #category : 'accessing' } @@ -297,12 +317,19 @@ MCPToolCallStats >> recordCall: aRecord [ lastOutlierExplanation := aRecord outlierExplanation ] +{ #category : 'recording' } +MCPToolCallStats >> recordErrorCountIn: aDictionary named: aString [ + + aDictionary at: aString put: (aDictionary at: aString ifAbsent: [ 0 ]) + 1 +] + { #category : 'recording' } MCPToolCallStats >> recordErrorFor: aRecord [ errorCount := self errorCount + 1. - aRecord errorClass ifNotEmpty: [ :className | - self errorCountsByClass at: className put: (self errorCountsByClass at: className ifAbsent: [ 0 ]) + 1 ] + aRecord errorClass ifNotEmpty: [ :className | self recordErrorCountIn: self errorCountsByClass named: className ]. + aRecord errorKind ifNotEmpty: [ :kind | self recordErrorCountIn: self errorCountsByKind named: kind ]. + aRecord errorCode ifNotEmpty: [ :code | self recordErrorCountIn: self errorCountsByCode named: code ] ] { #category : 'accessing' }