Skip to content

Commit c86bd67

Browse files
authored
Merge pull request #238 from mathworks/recordException
Record exception
2 parents 52f7084 + 6fd6aa8 commit c86bd67

File tree

5 files changed

+229
-23
lines changed

5 files changed

+229
-23
lines changed

api/trace/+opentelemetry/+trace/Span.m

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
classdef Span < handle
22
% A span that represents a unit of work within a trace.
33

4-
% Copyright 2023-2025 The MathWorks, Inc.
4+
% Copyright 2023-2026 The MathWorks, Inc.
55

66
properties
77
Name (1,1) string % Name of span
@@ -129,14 +129,76 @@ function addEvent(obj, eventname, varargin)
129129
attrs{1,i} = attrnames(i);
130130
attrs(2,i) = attrvalues(i);
131131
end
132-
obj.Proxy.addEvent(eventname, eventtime, attrs{:});
132+
obj.Proxy.addEvent(eventname, eventtime, attrs{:});
133133
end
134134

135-
function setStatus(obj, status, description)
135+
function recordException(obj, exception, varargin)
136+
% RECORDEXCEPTION Record an exception as an event.
137+
% RECORDEXCEPTION(SP, EXCEPTION) records a MATLAB exception
138+
% (MException object) as an event at the current time.
139+
%
140+
% RECORDEXCEPTION(SP, EXCEPTION, TIME) also specifies the event
141+
% time. If TIME does not have a time zone specified, it is
142+
% interpreted as a UTC time.
143+
%
144+
% RECORDEXCEPTION(..., ATTRIBUTES) or RECORDEXCEPTION(..., ATTRNAME1,
145+
% ATTRVALUE1, ATTRNAME2, ATTRVALUE2, ...) specifies additional
146+
% attribute name/value pairs for the event, either as a
147+
% dictionary or as trailing inputs.
148+
%
149+
% See also ADDEVENT, SETSTATUS
150+
151+
arguments
152+
obj
153+
exception (1,1) MException
154+
end
155+
arguments (Repeating)
156+
varargin
157+
end
158+
159+
% Process event time input first
160+
eventtime = [];
161+
remainingArgs = varargin;
162+
if ~isempty(remainingArgs) && isdatetime(remainingArgs{1})
163+
eventtime = remainingArgs{1};
164+
remainingArgs(1) = []; % remove the time input
165+
end
166+
167+
% Process any additional user-provided attributes
168+
[userAttrNames, userAttrValues] = opentelemetry.common.processAttributes(remainingArgs);
169+
170+
% Build the exception attributes
171+
exceptionAttrs = dictionary();
172+
173+
% Standard exception attributes
174+
exceptionAttrs("exception.identifier") = string(exception.identifier);
175+
exceptionAttrs("exception.message") = string(exception.message);
176+
exceptionAttrs("exception.stacktrace") = jsonencode(exception.stack);
177+
exceptionAttrs("exception.cause") = jsonencode(exception.cause);
178+
179+
% Merge user attributes with exception attributes
180+
% User attributes should not override the standard exception attributes
181+
for i = 1:length(userAttrNames)
182+
attrName = userAttrNames(i);
183+
if ~isKey(exceptionAttrs, attrName)
184+
exceptionAttrs(attrName) = userAttrValues{i};
185+
end
186+
% Silently ignore conflicting attributes
187+
end
188+
189+
% Call addEvent with the exception attributes
190+
if isempty(eventtime)
191+
obj.addEvent("exception", exceptionAttrs);
192+
else
193+
obj.addEvent("exception", eventtime, exceptionAttrs);
194+
end
195+
end
196+
197+
function setStatus(obj, status, description)
136198
% SETSTATUS Set the span status.
137199
% SETSTATUS(SP, STATUS) sets the span status as "Ok" or
138200
% "Error".
139-
%
201+
%
140202
% SETSTATUS(SP, STATUS, DESC) also specifies a description.
141203
% Description is only recorded if status is "Error".
142204
try

auto-instrumentation/+opentelemetry/+autoinstrument/AutoTrace.m

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
classdef AutoTrace < handle
22
% Automatic instrumentation with OpenTelemetry tracing.
33

4-
% Copyright 2024-2025 The MathWorks, Inc.
4+
% Copyright 2024-2026 The MathWorks, Inc.
55

66
properties (SetAccess=private)
77
StartFunction function_handle % entry function
@@ -155,7 +155,9 @@ function handleError(obj, ME)
155155
% spans and their corresponding scopes. Rethrow the
156156
% exception ME.
157157
if ~isempty(obj.Instrumentor.Spans)
158-
setStatus(obj.Instrumentor.Spans(end), "Error", ME.message);
158+
errorspan = obj.Instrumentor.Spans(end);
159+
setStatus(errorspan, "Error", ME.message);
160+
recordException(errorspan, ME);
159161
for i = length(obj.Instrumentor.Spans):-1:1
160162
obj.Instrumentor.Spans(i) = [];
161163
obj.Instrumentor.Scopes(i) = [];

test/tautotrace.m

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -289,24 +289,51 @@ function testError(testCase)
289289
at = opentelemetry.autoinstrument.AutoTrace(@linearfit_example);
290290

291291
% run the example with an invalid input, check for error
292-
verifyError(testCase, @()beginTrace(at, "invalid"), "autotrace_examples:linearfit_example:generate_data:InvalidN");
292+
errorid_expected = "autotrace_examples:linearfit_example:generate_data:InvalidN";
293+
errormsg_expected = "Input must be a numeric scalar";
294+
verifyError(testCase, @()beginTrace(at, "invalid"), errorid_expected);
293295

294296
% perform test comparisons
295297
results = readJsonResults(testCase);
296298
verifyNumElements(testCase, results, 2);
297299

298300
% check span names
299-
verifyEqual(testCase, string(results{1}.resourceSpans.scopeSpans.spans.name), "generate_data");
300-
verifyEqual(testCase, string(results{2}.resourceSpans.scopeSpans.spans.name), "linearfit_example");
301+
spannames = cellfun(@(x)string(x.resourceSpans.scopeSpans.spans.name), results);
302+
spannames_expected = ["generate_data" "linearfit_example"];
303+
[lia, locb] = ismember(spannames_expected, spannames);
304+
verifyTrue(testCase, all(lia));
305+
generatedata = results{locb(1)};
306+
linearfitexample = results{locb(2)};
301307

302308
% check parent children relationship
303-
verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.parentSpanId, results{2}.resourceSpans.scopeSpans.spans.spanId);
309+
verifyEqual(testCase, generatedata.resourceSpans.scopeSpans.spans.parentSpanId, linearfitexample.resourceSpans.scopeSpans.spans.spanId);
304310

305311
% check error status
306-
verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.code, 2); % error
307-
verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.message, ...
308-
'Input must be a numeric scalar');
309-
verifyEmpty(testCase, fieldnames(results{2}.resourceSpans.scopeSpans.spans.status)); % ok, no error
312+
verifyEqual(testCase, generatedata.resourceSpans.scopeSpans.spans.status.code, 2); % error
313+
verifyEqual(testCase, string(generatedata.resourceSpans.scopeSpans.spans.status.message), ...
314+
errormsg_expected);
315+
verifyEmpty(testCase, fieldnames(linearfitexample.resourceSpans.scopeSpans.spans.status)); % ok, no error
316+
317+
% check exception event
318+
verifyTrue(testCase, isfield(generatedata.resourceSpans.scopeSpans.spans, "events")); % exception event in "generate_data" span
319+
exception = generatedata.resourceSpans.scopeSpans.spans.events;
320+
verifyEqual(testCase, string(exception.name), "exception");
321+
% exception attributes
322+
exception_attrkeys = string({exception.attributes.key});
323+
identifieridx = find(exception_attrkeys == "exception.identifier");
324+
verifyNotEmpty(testCase, identifieridx);
325+
verifyEqual(testCase, string(exception.attributes(identifieridx).value.stringValue), errorid_expected);
326+
messageidx = find(exception_attrkeys == "exception.message");
327+
verifyNotEmpty(testCase, messageidx);
328+
verifyEqual(testCase, string(exception.attributes(messageidx).value.stringValue), errormsg_expected);
329+
causeidx = find(exception_attrkeys == "exception.cause");
330+
verifyNotEmpty(testCase, causeidx);
331+
verifyEqual(testCase, string(exception.attributes(causeidx).value.stringValue), "[]");
332+
stacktraceidx = find(exception_attrkeys == "exception.stacktrace");
333+
verifyNotEmpty(testCase, stacktraceidx);
334+
verifyTrue(testCase, contains(string(exception.attributes(stacktraceidx).value.stringValue), "generate_data.m"));
335+
336+
verifyFalse(testCase, isfield(linearfitexample.resourceSpans.scopeSpans.spans, "events")); % no exception event in "linearfit_example" span
310337
end
311338

312339
function testHandleError(testCase)
@@ -324,25 +351,52 @@ function testHandleError(testCase)
324351

325352
% call example directly instead of calling beginTrace, and pass
326353
% in an invalid input
354+
errorid_expected = "autotrace_examples:linearfit_example:generate_data:InvalidN";
355+
errormsg_expected = "Input must be a numeric scalar";
327356
verifyError(testCase, @()linearfit_example_trycatch(at, "invalid"), ...
328-
"autotrace_examples:linearfit_example:generate_data:InvalidN");
357+
errorid_expected);
329358

330359
% perform test comparisons
331360
results = readJsonResults(testCase);
332361
verifyNumElements(testCase, results, 2);
333362

334363
% check span names
335-
verifyEqual(testCase, string(results{1}.resourceSpans.scopeSpans.spans.name), "generate_data");
336-
verifyEqual(testCase, string(results{2}.resourceSpans.scopeSpans.spans.name), "linearfit_example_trycatch");
364+
spannames = cellfun(@(x)string(x.resourceSpans.scopeSpans.spans.name), results);
365+
spannames_expected = ["generate_data" "linearfit_example_trycatch"];
366+
[lia, locb] = ismember(spannames_expected, spannames);
367+
verifyTrue(testCase, all(lia));
368+
generatedata = results{locb(1)};
369+
linearfitexample = results{locb(2)};
337370

338371
% check parent children relationship
339-
verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.parentSpanId, results{2}.resourceSpans.scopeSpans.spans.spanId);
372+
verifyEqual(testCase, generatedata.resourceSpans.scopeSpans.spans.parentSpanId, linearfitexample.resourceSpans.scopeSpans.spans.spanId);
340373

341374
% check error status
342-
verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.code, 2); % error
343-
verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.message, ...
344-
'Input must be a numeric scalar');
345-
verifyEmpty(testCase, fieldnames(results{2}.resourceSpans.scopeSpans.spans.status)); % ok, no error
375+
verifyEqual(testCase, generatedata.resourceSpans.scopeSpans.spans.status.code, 2); % error
376+
verifyEqual(testCase, string(generatedata.resourceSpans.scopeSpans.spans.status.message), ...
377+
errormsg_expected);
378+
verifyEmpty(testCase, fieldnames(linearfitexample.resourceSpans.scopeSpans.spans.status)); % ok, no error
379+
380+
% check exception event
381+
verifyTrue(testCase, isfield(generatedata.resourceSpans.scopeSpans.spans, "events")); % exception event in "generate_data" span
382+
exception = generatedata.resourceSpans.scopeSpans.spans.events;
383+
verifyEqual(testCase, string(exception.name), "exception");
384+
% exception attributes
385+
exception_attrkeys = string({exception.attributes.key});
386+
identifieridx = find(exception_attrkeys == "exception.identifier");
387+
verifyNotEmpty(testCase, identifieridx);
388+
verifyEqual(testCase, string(exception.attributes(identifieridx).value.stringValue), errorid_expected);
389+
messageidx = find(exception_attrkeys == "exception.message");
390+
verifyNotEmpty(testCase, messageidx);
391+
verifyEqual(testCase, string(exception.attributes(messageidx).value.stringValue), errormsg_expected);
392+
causeidx = find(exception_attrkeys == "exception.cause");
393+
verifyNotEmpty(testCase, causeidx);
394+
verifyEqual(testCase, string(exception.attributes(causeidx).value.stringValue), "[]");
395+
stacktraceidx = find(exception_attrkeys == "exception.stacktrace");
396+
verifyNotEmpty(testCase, stacktraceidx);
397+
verifyTrue(testCase, contains(string(exception.attributes(stacktraceidx).value.stringValue), "generate_data.m"));
398+
399+
verifyFalse(testCase, isfield(linearfitexample.resourceSpans.scopeSpans.spans, "events")); % no exception event in "linearfit_example_trycatch" span
346400
end
347401

348402
function testMultipleInstances(testCase)

test/ttrace.m

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
classdef ttrace < matlab.unittest.TestCase
22
% tests for traces and spans
33

4-
% Copyright 2023-2025 The MathWorks, Inc.
4+
% Copyright 2023-2026 The MathWorks, Inc.
55

66
properties
77
OtelConfigFile
@@ -633,6 +633,79 @@ function testEvents(testCase)
633633

634634
end
635635

636+
function testRecordException(testCase)
637+
% Test recording a simple exception
638+
639+
% Define exception properties
640+
errid = "TestID:SimpleError";
641+
errmsg = "This is a test error message";
642+
643+
% Create tracer and span
644+
tp = opentelemetry.sdk.trace.TracerProvider();
645+
tracer = getTracer(tp, "test_tracer");
646+
span = startSpan(tracer, "test_span");
647+
648+
% Generate exception
649+
except = generateException(errid, errmsg);
650+
% add a cause
651+
causeid = "TestID:CauseError";
652+
causemsg = "This is the cause";
653+
cause = generateException(causeid, causemsg);
654+
except = addCause(except, cause);
655+
656+
% record exception with an extra attribute
657+
attr1name = "foo";
658+
attr1val = "bar";
659+
recordException(span, except, attr1name, attr1val);
660+
661+
endSpan(span);
662+
663+
% Get results
664+
results = readJsonResults(testCase);
665+
666+
% Verify event exists and has correct name
667+
events = results{1}.resourceSpans.scopeSpans.spans.events;
668+
verifyEqual(testCase, length(events), 1);
669+
verifyEqual(testCase, events(1).name, 'exception');
670+
671+
% Get all attribute keys
672+
eventAttrs = events(1).attributes;
673+
attrKeys = string({eventAttrs.key});
674+
675+
% Verify exception.identifier
676+
idIdx = find(attrKeys == "exception.identifier");
677+
verifyNotEmpty(testCase, idIdx);
678+
verifyEqual(testCase, string(eventAttrs(idIdx).value.stringValue), errid);
679+
680+
% Verify exception.message
681+
msgIdx = find(attrKeys == "exception.message");
682+
verifyNotEmpty(testCase, msgIdx);
683+
verifyEqual(testCase, string(eventAttrs(msgIdx).value.stringValue), errmsg);
684+
685+
% Verify exception.stacktrace
686+
stackIdx = find(attrKeys == "exception.stacktrace");
687+
verifyNotEmpty(testCase, stackIdx);
688+
stack = jsondecode(string(eventAttrs(stackIdx).value.stringValue));
689+
verifyNotEmpty(testCase, stack); % Should have stack frames now
690+
verifyEqual(testCase, stack, except.stack);
691+
692+
% Verify exception.cause
693+
causeIdx = find(attrKeys == "exception.cause");
694+
verifyNotEmpty(testCase, causeIdx);
695+
causeAttr = jsondecode(string(eventAttrs(causeIdx).value.stringValue));
696+
verifyEqual(testCase, causeAttr.identifier, cause.identifier);
697+
verifyEqual(testCase, causeAttr.message, cause.message);
698+
verifyEqual(testCase, causeAttr.stack, cause.stack);
699+
verifyEmpty(testCase, causeAttr.cause);
700+
verifyEmpty(testCase, causeAttr.Correction);
701+
702+
% Verify extra attribute
703+
attrIdx = find(attrKeys == attr1name);
704+
verifyNotEmpty(testCase, attrIdx);
705+
verifyEqual(testCase, string(eventAttrs(attrIdx).value.stringValue), attr1val);
706+
end
707+
708+
636709
function testLinks(testCase)
637710
% testLinks: specifying links between spans
638711

test/utils/generateException.m

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
function me = generateException(errid, errmsg)
2+
% Return a thrown exception
3+
4+
% Copyright 2026 The MathWorks, Inc.
5+
6+
try
7+
exceptionHelper(errid, errmsg);
8+
catch me
9+
end
10+
end
11+
12+
function exceptionHelper(errid, errmsg)
13+
me = MException(errid, errmsg);
14+
throw(me);
15+
end

0 commit comments

Comments
 (0)