diff --git a/api/trace/+opentelemetry/+trace/Span.m b/api/trace/+opentelemetry/+trace/Span.m index 4ee262a..51eb08b 100644 --- a/api/trace/+opentelemetry/+trace/Span.m +++ b/api/trace/+opentelemetry/+trace/Span.m @@ -1,7 +1,7 @@ classdef Span < handle % A span that represents a unit of work within a trace. -% Copyright 2023-2025 The MathWorks, Inc. +% Copyright 2023-2026 The MathWorks, Inc. properties Name (1,1) string % Name of span @@ -129,14 +129,76 @@ function addEvent(obj, eventname, varargin) attrs{1,i} = attrnames(i); attrs(2,i) = attrvalues(i); end - obj.Proxy.addEvent(eventname, eventtime, attrs{:}); + obj.Proxy.addEvent(eventname, eventtime, attrs{:}); end - function setStatus(obj, status, description) + function recordException(obj, exception, varargin) + % RECORDEXCEPTION Record an exception as an event. + % RECORDEXCEPTION(SP, EXCEPTION) records a MATLAB exception + % (MException object) as an event at the current time. + % + % RECORDEXCEPTION(SP, EXCEPTION, TIME) also specifies the event + % time. If TIME does not have a time zone specified, it is + % interpreted as a UTC time. + % + % RECORDEXCEPTION(..., ATTRIBUTES) or RECORDEXCEPTION(..., ATTRNAME1, + % ATTRVALUE1, ATTRNAME2, ATTRVALUE2, ...) specifies additional + % attribute name/value pairs for the event, either as a + % dictionary or as trailing inputs. + % + % See also ADDEVENT, SETSTATUS + + arguments + obj + exception (1,1) MException + end + arguments (Repeating) + varargin + end + + % Process event time input first + eventtime = []; + remainingArgs = varargin; + if ~isempty(remainingArgs) && isdatetime(remainingArgs{1}) + eventtime = remainingArgs{1}; + remainingArgs(1) = []; % remove the time input + end + + % Process any additional user-provided attributes + [userAttrNames, userAttrValues] = opentelemetry.common.processAttributes(remainingArgs); + + % Build the exception attributes + exceptionAttrs = dictionary(); + + % Standard exception attributes + exceptionAttrs("exception.identifier") = string(exception.identifier); + exceptionAttrs("exception.message") = string(exception.message); + exceptionAttrs("exception.stacktrace") = jsonencode(exception.stack); + exceptionAttrs("exception.cause") = jsonencode(exception.cause); + + % Merge user attributes with exception attributes + % User attributes should not override the standard exception attributes + for i = 1:length(userAttrNames) + attrName = userAttrNames(i); + if ~isKey(exceptionAttrs, attrName) + exceptionAttrs(attrName) = userAttrValues{i}; + end + % Silently ignore conflicting attributes + end + + % Call addEvent with the exception attributes + if isempty(eventtime) + obj.addEvent("exception", exceptionAttrs); + else + obj.addEvent("exception", eventtime, exceptionAttrs); + end + end + + function setStatus(obj, status, description) % SETSTATUS Set the span status. % SETSTATUS(SP, STATUS) sets the span status as "Ok" or % "Error". - % + % % SETSTATUS(SP, STATUS, DESC) also specifies a description. % Description is only recorded if status is "Error". try diff --git a/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTrace.m b/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTrace.m index e724cf8..7d8c5a0 100644 --- a/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTrace.m +++ b/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTrace.m @@ -1,7 +1,7 @@ classdef AutoTrace < handle % Automatic instrumentation with OpenTelemetry tracing. - % Copyright 2024-2025 The MathWorks, Inc. + % Copyright 2024-2026 The MathWorks, Inc. properties (SetAccess=private) StartFunction function_handle % entry function @@ -155,7 +155,9 @@ function handleError(obj, ME) % spans and their corresponding scopes. Rethrow the % exception ME. if ~isempty(obj.Instrumentor.Spans) - setStatus(obj.Instrumentor.Spans(end), "Error", ME.message); + errorspan = obj.Instrumentor.Spans(end); + setStatus(errorspan, "Error", ME.message); + recordException(errorspan, ME); for i = length(obj.Instrumentor.Spans):-1:1 obj.Instrumentor.Spans(i) = []; obj.Instrumentor.Scopes(i) = []; diff --git a/test/tautotrace.m b/test/tautotrace.m index c898efc..7e193fb 100644 --- a/test/tautotrace.m +++ b/test/tautotrace.m @@ -289,24 +289,51 @@ function testError(testCase) at = opentelemetry.autoinstrument.AutoTrace(@linearfit_example); % run the example with an invalid input, check for error - verifyError(testCase, @()beginTrace(at, "invalid"), "autotrace_examples:linearfit_example:generate_data:InvalidN"); + errorid_expected = "autotrace_examples:linearfit_example:generate_data:InvalidN"; + errormsg_expected = "Input must be a numeric scalar"; + verifyError(testCase, @()beginTrace(at, "invalid"), errorid_expected); % perform test comparisons results = readJsonResults(testCase); verifyNumElements(testCase, results, 2); % check span names - verifyEqual(testCase, string(results{1}.resourceSpans.scopeSpans.spans.name), "generate_data"); - verifyEqual(testCase, string(results{2}.resourceSpans.scopeSpans.spans.name), "linearfit_example"); + spannames = cellfun(@(x)string(x.resourceSpans.scopeSpans.spans.name), results); + spannames_expected = ["generate_data" "linearfit_example"]; + [lia, locb] = ismember(spannames_expected, spannames); + verifyTrue(testCase, all(lia)); + generatedata = results{locb(1)}; + linearfitexample = results{locb(2)}; % check parent children relationship - verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.parentSpanId, results{2}.resourceSpans.scopeSpans.spans.spanId); + verifyEqual(testCase, generatedata.resourceSpans.scopeSpans.spans.parentSpanId, linearfitexample.resourceSpans.scopeSpans.spans.spanId); % check error status - verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.code, 2); % error - verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.message, ... - 'Input must be a numeric scalar'); - verifyEmpty(testCase, fieldnames(results{2}.resourceSpans.scopeSpans.spans.status)); % ok, no error + verifyEqual(testCase, generatedata.resourceSpans.scopeSpans.spans.status.code, 2); % error + verifyEqual(testCase, string(generatedata.resourceSpans.scopeSpans.spans.status.message), ... + errormsg_expected); + verifyEmpty(testCase, fieldnames(linearfitexample.resourceSpans.scopeSpans.spans.status)); % ok, no error + + % check exception event + verifyTrue(testCase, isfield(generatedata.resourceSpans.scopeSpans.spans, "events")); % exception event in "generate_data" span + exception = generatedata.resourceSpans.scopeSpans.spans.events; + verifyEqual(testCase, string(exception.name), "exception"); + % exception attributes + exception_attrkeys = string({exception.attributes.key}); + identifieridx = find(exception_attrkeys == "exception.identifier"); + verifyNotEmpty(testCase, identifieridx); + verifyEqual(testCase, string(exception.attributes(identifieridx).value.stringValue), errorid_expected); + messageidx = find(exception_attrkeys == "exception.message"); + verifyNotEmpty(testCase, messageidx); + verifyEqual(testCase, string(exception.attributes(messageidx).value.stringValue), errormsg_expected); + causeidx = find(exception_attrkeys == "exception.cause"); + verifyNotEmpty(testCase, causeidx); + verifyEqual(testCase, string(exception.attributes(causeidx).value.stringValue), "[]"); + stacktraceidx = find(exception_attrkeys == "exception.stacktrace"); + verifyNotEmpty(testCase, stacktraceidx); + verifyTrue(testCase, contains(string(exception.attributes(stacktraceidx).value.stringValue), "generate_data.m")); + + verifyFalse(testCase, isfield(linearfitexample.resourceSpans.scopeSpans.spans, "events")); % no exception event in "linearfit_example" span end function testHandleError(testCase) @@ -324,25 +351,52 @@ function testHandleError(testCase) % call example directly instead of calling beginTrace, and pass % in an invalid input + errorid_expected = "autotrace_examples:linearfit_example:generate_data:InvalidN"; + errormsg_expected = "Input must be a numeric scalar"; verifyError(testCase, @()linearfit_example_trycatch(at, "invalid"), ... - "autotrace_examples:linearfit_example:generate_data:InvalidN"); + errorid_expected); % perform test comparisons results = readJsonResults(testCase); verifyNumElements(testCase, results, 2); % check span names - verifyEqual(testCase, string(results{1}.resourceSpans.scopeSpans.spans.name), "generate_data"); - verifyEqual(testCase, string(results{2}.resourceSpans.scopeSpans.spans.name), "linearfit_example_trycatch"); + spannames = cellfun(@(x)string(x.resourceSpans.scopeSpans.spans.name), results); + spannames_expected = ["generate_data" "linearfit_example_trycatch"]; + [lia, locb] = ismember(spannames_expected, spannames); + verifyTrue(testCase, all(lia)); + generatedata = results{locb(1)}; + linearfitexample = results{locb(2)}; % check parent children relationship - verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.parentSpanId, results{2}.resourceSpans.scopeSpans.spans.spanId); + verifyEqual(testCase, generatedata.resourceSpans.scopeSpans.spans.parentSpanId, linearfitexample.resourceSpans.scopeSpans.spans.spanId); % check error status - verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.code, 2); % error - verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.message, ... - 'Input must be a numeric scalar'); - verifyEmpty(testCase, fieldnames(results{2}.resourceSpans.scopeSpans.spans.status)); % ok, no error + verifyEqual(testCase, generatedata.resourceSpans.scopeSpans.spans.status.code, 2); % error + verifyEqual(testCase, string(generatedata.resourceSpans.scopeSpans.spans.status.message), ... + errormsg_expected); + verifyEmpty(testCase, fieldnames(linearfitexample.resourceSpans.scopeSpans.spans.status)); % ok, no error + + % check exception event + verifyTrue(testCase, isfield(generatedata.resourceSpans.scopeSpans.spans, "events")); % exception event in "generate_data" span + exception = generatedata.resourceSpans.scopeSpans.spans.events; + verifyEqual(testCase, string(exception.name), "exception"); + % exception attributes + exception_attrkeys = string({exception.attributes.key}); + identifieridx = find(exception_attrkeys == "exception.identifier"); + verifyNotEmpty(testCase, identifieridx); + verifyEqual(testCase, string(exception.attributes(identifieridx).value.stringValue), errorid_expected); + messageidx = find(exception_attrkeys == "exception.message"); + verifyNotEmpty(testCase, messageidx); + verifyEqual(testCase, string(exception.attributes(messageidx).value.stringValue), errormsg_expected); + causeidx = find(exception_attrkeys == "exception.cause"); + verifyNotEmpty(testCase, causeidx); + verifyEqual(testCase, string(exception.attributes(causeidx).value.stringValue), "[]"); + stacktraceidx = find(exception_attrkeys == "exception.stacktrace"); + verifyNotEmpty(testCase, stacktraceidx); + verifyTrue(testCase, contains(string(exception.attributes(stacktraceidx).value.stringValue), "generate_data.m")); + + verifyFalse(testCase, isfield(linearfitexample.resourceSpans.scopeSpans.spans, "events")); % no exception event in "linearfit_example_trycatch" span end function testMultipleInstances(testCase) diff --git a/test/ttrace.m b/test/ttrace.m index 5f722f5..a893066 100644 --- a/test/ttrace.m +++ b/test/ttrace.m @@ -1,7 +1,7 @@ classdef ttrace < matlab.unittest.TestCase % tests for traces and spans - % Copyright 2023-2025 The MathWorks, Inc. + % Copyright 2023-2026 The MathWorks, Inc. properties OtelConfigFile @@ -633,6 +633,79 @@ function testEvents(testCase) end + function testRecordException(testCase) + % Test recording a simple exception + + % Define exception properties + errid = "TestID:SimpleError"; + errmsg = "This is a test error message"; + + % Create tracer and span + tp = opentelemetry.sdk.trace.TracerProvider(); + tracer = getTracer(tp, "test_tracer"); + span = startSpan(tracer, "test_span"); + + % Generate exception + except = generateException(errid, errmsg); + % add a cause + causeid = "TestID:CauseError"; + causemsg = "This is the cause"; + cause = generateException(causeid, causemsg); + except = addCause(except, cause); + + % record exception with an extra attribute + attr1name = "foo"; + attr1val = "bar"; + recordException(span, except, attr1name, attr1val); + + endSpan(span); + + % Get results + results = readJsonResults(testCase); + + % Verify event exists and has correct name + events = results{1}.resourceSpans.scopeSpans.spans.events; + verifyEqual(testCase, length(events), 1); + verifyEqual(testCase, events(1).name, 'exception'); + + % Get all attribute keys + eventAttrs = events(1).attributes; + attrKeys = string({eventAttrs.key}); + + % Verify exception.identifier + idIdx = find(attrKeys == "exception.identifier"); + verifyNotEmpty(testCase, idIdx); + verifyEqual(testCase, string(eventAttrs(idIdx).value.stringValue), errid); + + % Verify exception.message + msgIdx = find(attrKeys == "exception.message"); + verifyNotEmpty(testCase, msgIdx); + verifyEqual(testCase, string(eventAttrs(msgIdx).value.stringValue), errmsg); + + % Verify exception.stacktrace + stackIdx = find(attrKeys == "exception.stacktrace"); + verifyNotEmpty(testCase, stackIdx); + stack = jsondecode(string(eventAttrs(stackIdx).value.stringValue)); + verifyNotEmpty(testCase, stack); % Should have stack frames now + verifyEqual(testCase, stack, except.stack); + + % Verify exception.cause + causeIdx = find(attrKeys == "exception.cause"); + verifyNotEmpty(testCase, causeIdx); + causeAttr = jsondecode(string(eventAttrs(causeIdx).value.stringValue)); + verifyEqual(testCase, causeAttr.identifier, cause.identifier); + verifyEqual(testCase, causeAttr.message, cause.message); + verifyEqual(testCase, causeAttr.stack, cause.stack); + verifyEmpty(testCase, causeAttr.cause); + verifyEmpty(testCase, causeAttr.Correction); + + % Verify extra attribute + attrIdx = find(attrKeys == attr1name); + verifyNotEmpty(testCase, attrIdx); + verifyEqual(testCase, string(eventAttrs(attrIdx).value.stringValue), attr1val); + end + + function testLinks(testCase) % testLinks: specifying links between spans diff --git a/test/utils/generateException.m b/test/utils/generateException.m new file mode 100644 index 0000000..fd7bf09 --- /dev/null +++ b/test/utils/generateException.m @@ -0,0 +1,15 @@ +function me = generateException(errid, errmsg) +% Return a thrown exception + +% Copyright 2026 The MathWorks, Inc. + +try + exceptionHelper(errid, errmsg); +catch me +end +end + +function exceptionHelper(errid, errmsg) +me = MException(errid, errmsg); +throw(me); +end