diff --git a/CMakeLists.txt b/CMakeLists.txt index 873cde1..67a72ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -476,6 +476,7 @@ set(TRACE_SDK_MATLAB_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/sdk/trace/+opentelemetr set(METRICS_SDK_MATLAB_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/sdk/metrics/+opentelemetry) set(LOGS_SDK_MATLAB_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/sdk/logs/+opentelemetry) set(COMMON_SDK_MATLAB_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/sdk/common/+opentelemetry) +set(AUTO_INSTRUMENTATION_MATLAB_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/auto-instrumentation/+opentelemetry) set(EXPORTER_MATLAB_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/exporters/otlp/+opentelemetry/+exporters/+otlp/defaultSpanExporter.m ${CMAKE_CURRENT_SOURCE_DIR}/exporters/otlp/+opentelemetry/+exporters/+otlp/defaultMetricExporter.m @@ -510,6 +511,7 @@ install(DIRECTORY ${TRACE_SDK_MATLAB_SOURCES} DESTINATION .) install(DIRECTORY ${METRICS_SDK_MATLAB_SOURCES} DESTINATION .) install(DIRECTORY ${LOGS_SDK_MATLAB_SOURCES} DESTINATION .) install(DIRECTORY ${COMMON_SDK_MATLAB_SOURCES} DESTINATION .) +install(DIRECTORY ${AUTO_INSTRUMENTATION_MATLAB_SOURCES} DESTINATION .) install(FILES ${EXPORTER_MATLAB_SOURCES} DESTINATION ${OTLP_EXPORTERS_DIR}) if(WITH_OTLP_HTTP) install(FILES ${OTLP_HTTP_EXPORTER_MATLAB_SOURCES} DESTINATION ${OTLP_EXPORTERS_DIR}) diff --git a/README.md b/README.md index df80e89..23ff4cf 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ otelcol --config For more examples, see the "examples" folder. +## Automatic Instrumentation +See example [here](auto-instrumentation/README.md). + ## Help To view documentation of individual function, type "help \\". For example, ``` diff --git a/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTrace.m b/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTrace.m new file mode 100644 index 0000000..27b75a3 --- /dev/null +++ b/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTrace.m @@ -0,0 +1,175 @@ +classdef AutoTrace < handle + % Automatic instrumentation with OpenTelemetry tracing. + + % Copyright 2024 The MathWorks, Inc. + + properties (SetAccess=private) + StartFunction function_handle % entry function + InstrumentedFiles string % list of M-files that are auto-instrumented + end + + properties (Access=private) + Instrumentor (1,1) opentelemetry.autoinstrument.AutoTraceInstrumentor % helper object + end + + methods + function obj = AutoTrace(startfun, options) + % AutoTrace Automatic instrumentation with OpenTelemetry tracing + % AT = OPENTELEMETRY.AUTOINSTRUMENT.AUTOTRACE(FUN) where FUN + % is a function handle, automatically instruments the function + % and all the functions in the same file, as well as their dependencies. + % For each function, a span is automatically started and made + % current at the beginning, and ended at the end. Returns an + % object AT. When AT is cleared or goes out-of-scope, automatic + % instrumentation will stop and the functions will no longer + % be instrumented. + % + % If called in a deployable archive (CTF file), all M-files + % included in the CTF will be instrumented. + % + % AT = OPENTELEMETRY.AUTOINSTRUMENT.AUTOTRACE(FUN, NAME1, VALUE1, + % NAME2, VALUE2, ...) specifies optional name-value pairs. + % Supported options are: + % "AdditionalFiles" - List of additional file names to + % include. Specifying additional files + % are useful in cases when automatic + % dependency detection failed to include them. + % For example, MATLAB Toolbox functions + % authored by MathWorks are excluded by default. + % "ExcludeFiles" - List of file names to exclude + % "AutoDetectFiles" - Whether to automatically include dependencies + % of FUN, specified as a logical scalar. + % Default value is true. + % "TracerName" - Specifies the name of the tracer + % the automatic spans are generated from + % "TracerVersion" - The tracer version + % "TracerSchema" - The tracer schema + % "Attributes" - Add attributes to all the automatic spans. + % Attributes must be specified as a dictionary. + % "SpanKind" - Span kind of the automatic spans + arguments + startfun (1,1) function_handle + options.TracerName {mustBeTextScalar} = "AutoTrace" + options.TracerVersion {mustBeTextScalar} = "" + options.TracerSchema {mustBeTextScalar} = "" + options.SpanKind {mustBeTextScalar} + options.Attributes {mustBeA(options.Attributes, "dictionary")} + options.ExcludeFiles {mustBeText} + options.AdditionalFiles {mustBeText} + options.AutoDetectFiles (1,1) {mustBeNumericOrLogical} = true + end + obj.StartFunction = startfun; + startfunname = func2str(startfun); + processFileInput(startfunname); % validate startfun + if options.AutoDetectFiles + if isdeployed + % matlab.codetools.requiredFilesAndProducts is not + % deployable. Instead instrument all files under CTFROOT + fileinfo = dir(fullfile(ctfroot, "**", "*.m")); + files = fullfile(string({fileinfo.folder}), string({fileinfo.name})); + + % filter out internal files in the toolbox directory + files = files(~startsWith(files, fullfile(ctfroot, "toolbox"))); + else + %#exclude matlab.codetools.requiredFilesAndProducts + files = string(matlab.codetools.requiredFilesAndProducts(startfunname)); + end + else + % only include the input file, not its dependencies + files = string(which(startfunname)); + end + % add extra files, this is intended for files + % matlab.codetools.requiredFilesAndProducts somehow missed + if isfield(options, "AdditionalFiles") + incfiles = string(options.AdditionalFiles); + for i = 1:numel(incfiles) + incfiles(i) = which(incfiles(i)); % get the full path + processFileInput(incfiles(i)); % validate additional file + end + files = union(files, incfiles); + end + + % make sure files are unique + files = unique(files); + + % filter out excluded files + if isfield(options, "ExcludeFiles") + excfiles = string(options.ExcludeFiles); + for i = 1:numel(excfiles) + excfiles(i) = which(excfiles(i)); % get the full path + end + files = setdiff(files, excfiles); + end + % filter out OpenTelemetry files, in case manual + % instrumentation is also used + files = files(~contains(files, ["+opentelemetry" "+libmexclass"])); + + for i = 1:length(files) + currfile = files(i); + if currfile =="" % ignore empties + continue + end + obj.Instrumentor.instrument(currfile, options); + obj.InstrumentedFiles(end+1,1) = currfile; + end + end + + function delete(obj) + obj.Instrumentor.cleanup(obj.InstrumentedFiles); + end + + function varargout = beginTrace(obj, varargin) + % beginTrace Run the auto-instrumented function + % [OUT1, OUT2, ...] = BEGINTRACE(AT, IN1, IN2, ...) calls the + % instrumented function with error handling. In case of + % error, all running spans will end and the last span will + % set to an "Error" status. The instrumented function is + % called with the synax [OUT1, OUT2, ...] = FUN(IN1, IN2, ...) + % + % See also OPENTELEMETRY.AUTOINSTRUMENT.AUTOTRACE/HANDLEERROR + try + varargout = cell(1,nargout); + [varargout{:}] = feval(obj.StartFunction, varargin{:}); + catch ME + handleError(obj, ME); + end + end + + function handleError(obj, ME) + % handleError Perform cleanup in case of an error + % HANDLEERROR(AT, ME) performs cleanup by ending all running + % spans and their corresponding scopes. Rethrow the + % exception ME. + if ~isempty(obj.Instrumentor.Spans) + setStatus(obj.Instrumentor.Spans(end), "Error"); + for i = length(obj.Instrumentor.Spans):-1:1 + obj.Instrumentor.Spans(i) = []; + obj.Instrumentor.Scopes(i) = []; + end + end + rethrow(ME); + end + end + + +end + +% check input file is valid +function processFileInput(f) +f = string(f); % force into a string +if startsWith(f, '@') % check for anonymous function + error("opentelemetry:autoinstrument:AutoTrace:AnonymousFunction", ... + replace(f, "\", "\\") + " is an anonymous function and is not supported."); +end +[~,~,fext] = fileparts(f); % check file extension +filetype = exist(f, "file"); % check file type +if ~(filetype == 2 && ismember(fext, ["" ".m" ".mlx"])) + if exist(f, "builtin") + error("opentelemetry:autoinstrument:AutoTrace:BuiltinFunction", ... + replace(f, "\", "\\") + " is a builtin function and is not supported."); + else + error("opentelemetry:autoinstrument:AutoTrace:InvalidMFile", ... + replace(f, "\", "\\") + " is not found or is not a valid MATLAB file with a .m or .mlx extension."); + end +end +end \ No newline at end of file diff --git a/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTraceInstrumentor.p b/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTraceInstrumentor.p new file mode 100644 index 0000000..ea478ae Binary files /dev/null and b/auto-instrumentation/+opentelemetry/+autoinstrument/AutoTraceInstrumentor.p differ diff --git a/auto-instrumentation/README.md b/auto-instrumentation/README.md new file mode 100644 index 0000000..48622fe --- /dev/null +++ b/auto-instrumentation/README.md @@ -0,0 +1,35 @@ +# Automatic Instrumentation + +Automatic instrumentation provides a way to instrument MATLAB code with OpenTelemetry data without requiring any code changes. + +## AutoTrace +With AutoTrace enabled, spans are automatically started at function beginnings and ended when functions end. By default, AutoTrace instruments the input function and all of its dependencies. An example workflow is as follows: +``` +% The example functions should be on the path when calling AutoTrace +addpath("myexample"); + +% Configure a tracer provider and set it as the global instance +tp = opentelemetry.sdk.trace.TracerProvider; % use default settings +setTracerProvider(tp); + +% Instrument the code +at = opentelemetry.autoinstrument.AutoTrace(@myexample, TracerName="AutoTraceExample"); + +% Start the example +beginTrace(at); +``` +Using the `beginTrace` method ensures proper error handling. In the case of an error, `beginTrace` will end all spans and set the "Error" status. + +Alternatively, you can also get the same behavior by inserting a try-catch in the starting function. +``` +function myexample(at) +% wrap a try catch around the code +try + % example code goes here +catch ME + handleError(at); +end +``` +With the try-catch, `beginTrace` method is no longer necessary and you can simply call `myexample` directly and pass in the AutoTrace object. + +To disable automatic tracing, delete the object returned by `AutoTrace`. diff --git a/test/autotrace_examples/example1/best_fit_line.m b/test/autotrace_examples/example1/best_fit_line.m new file mode 100644 index 0000000..55a404a --- /dev/null +++ b/test/autotrace_examples/example1/best_fit_line.m @@ -0,0 +1,7 @@ +function yf = best_fit_line(x, y) +% example code for testing auto instrumentation + +% Copyright 2024 The MathWorks, Inc. + +coefs = polyfit(x, y, 1); +yf = polyval(coefs , x); diff --git a/test/autotrace_examples/example1/example1.m b/test/autotrace_examples/example1/example1.m new file mode 100644 index 0000000..f47f8aa --- /dev/null +++ b/test/autotrace_examples/example1/example1.m @@ -0,0 +1,8 @@ +function yf = example1(n) +% example code for testing auto instrumentation. Input n is the number of +% data points. + +% Copyright 2024 The MathWorks, Inc. + +[x, y] = generate_data(n); +yf = best_fit_line(x,y); \ No newline at end of file diff --git a/test/autotrace_examples/example1/example1_trycatch.m b/test/autotrace_examples/example1/example1_trycatch.m new file mode 100644 index 0000000..2d58fc1 --- /dev/null +++ b/test/autotrace_examples/example1/example1_trycatch.m @@ -0,0 +1,12 @@ +function yf = example1_trycatch(at, n) +% example code for testing auto instrumentation. This example should not +% use beginTrace method and instead should be called directly. + +% Copyright 2024 The MathWorks, Inc. + +try + [x, y] = generate_data(n); + yf = best_fit_line(x,y); +catch ME + handleError(at, ME); +end \ No newline at end of file diff --git a/test/autotrace_examples/example1/generate_data.m b/test/autotrace_examples/example1/generate_data.m new file mode 100644 index 0000000..82fb09b --- /dev/null +++ b/test/autotrace_examples/example1/generate_data.m @@ -0,0 +1,17 @@ +function [x, y] = generate_data(n) +% example code for testing auto instrumentation + +% Copyright 2024 The MathWorks, Inc. + +% check input is valid +if ~(isnumeric(n) && isscalar(n)) + error("autotrace_examples:example1:generate_data:InvalidN", ... + "Input must be a numeric scalar"); +end + +% generate some random data +a = 1.5; +b = 0.8; +sigma = 5; +x = 1:n; +y = a * x + b + sigma * randn(1, n); \ No newline at end of file diff --git a/test/tautotrace.m b/test/tautotrace.m new file mode 100644 index 0000000..63f9387 --- /dev/null +++ b/test/tautotrace.m @@ -0,0 +1,251 @@ +classdef tautotrace < matlab.unittest.TestCase + % tests for AutoTrace + + % Copyright 2024 The MathWorks, Inc. + + properties + OtelConfigFile + JsonFile + PidFile + OtelcolName + Otelcol + ListPid + ReadPidList + ExtractPid + Sigint + Sigterm + end + + methods (TestClassSetup) + function setupOnce(testCase) + % add the utils folder to the path + utilsfolder = fullfile(fileparts(mfilename('fullpath')), "utils"); + testCase.applyFixture(matlab.unittest.fixtures.PathFixture(utilsfolder)); + % add the example folders to the path + example1folder = fullfile(fileparts(mfilename('fullpath')), "autotrace_examples", "example1"); + testCase.applyFixture(matlab.unittest.fixtures.PathFixture(example1folder)); + commonSetupOnce(testCase); + + % configure the global tracer provider + tp = opentelemetry.sdk.trace.TracerProvider(); + setTracerProvider(tp); + end + end + + methods (TestMethodSetup) + function setup(testCase) + commonSetup(testCase); + end + end + + methods (TestMethodTeardown) + function teardown(testCase) + commonTeardown(testCase); + end + end + + methods (Test) + function testBasic(testCase) + % testBasic: instrument a simple example + + % set up AutoTrace + at = opentelemetry.autoinstrument.AutoTrace(@example1); + + % run the example + [~] = beginTrace(at, 100); + + % perform test comparisons + results = readJsonResults(testCase); + verifyNumElements(testCase, results, 3); + + % check tracer and span names + verifyEqual(testCase, string(results{1}.resourceSpans.scopeSpans.scope.name), "AutoTrace"); % default name + verifyEqual(testCase, string(results{1}.resourceSpans.scopeSpans.spans.name), "generate_data"); + verifyEqual(testCase, string(results{2}.resourceSpans.scopeSpans.spans.name), "best_fit_line"); + verifyEqual(testCase, string(results{3}.resourceSpans.scopeSpans.spans.name), "example1"); + + % check they belong to the same trace + verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.traceId, results{2}.resourceSpans.scopeSpans.spans.traceId); + verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.traceId, results{3}.resourceSpans.scopeSpans.spans.traceId); + + % check parent children relationship + verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.parentSpanId, results{3}.resourceSpans.scopeSpans.spans.spanId); + verifyEqual(testCase, results{2}.resourceSpans.scopeSpans.spans.parentSpanId, results{3}.resourceSpans.scopeSpans.spans.spanId); + end + + function testIncludeExcludeFiles(testCase) + % testIncludeExcludeFiles: AdditionalFiles and ExcludeFiles options + + % set up AutoTrace + at = opentelemetry.autoinstrument.AutoTrace(@example1, ... + "AdditionalFiles", "polyfit", "ExcludeFiles", "generate_data"); + + % run the example + [~] = beginTrace(at, 100); + + % perform test comparisons + results = readJsonResults(testCase); + verifyNumElements(testCase, results, 3); + + % check span names + verifyEqual(testCase, string(results{1}.resourceSpans.scopeSpans.spans.name), "polyfit"); + verifyEqual(testCase, string(results{2}.resourceSpans.scopeSpans.spans.name), "best_fit_line"); + verifyEqual(testCase, string(results{3}.resourceSpans.scopeSpans.spans.name), "example1"); + + % check parent children relationship + verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.parentSpanId, results{2}.resourceSpans.scopeSpans.spans.spanId); + verifyEqual(testCase, results{2}.resourceSpans.scopeSpans.spans.parentSpanId, results{3}.resourceSpans.scopeSpans.spans.spanId); + end + + function testDisableFileDetection(testCase) + % testDisableFileDetection: AutoDetectFiles set to false + + % set up AutoTrace + at = opentelemetry.autoinstrument.AutoTrace(@example1, ... + "AutoDetectFiles", false); + + % run the example + [~] = beginTrace(at, 100); + + % perform test comparisons + results = readJsonResults(testCase); + + % should only be 1 span + verifyNumElements(testCase, results, 1); + verifyEqual(testCase, string(results{1}.resourceSpans.scopeSpans.spans.name), "example1"); + end + + function testNonFileOptions(testCase) + % testNonFileOptions: other options not related to files, + % "TracerName", "TracerVersion", "TracerSchema", "Attributes", + % "SpanKind" + + tracername = "foo"; + tracerversion = "1.1"; + tracerschema = "https://opentelemetry.io/schemas/1.28.0"; + spankind = "consumer"; + attrnames = ["foo" "bar"]; + attrvalues = [1 2]; + attrs = dictionary(attrnames, attrvalues); + % set up AutoTrace + at = opentelemetry.autoinstrument.AutoTrace(@example1, ... + "TracerName", tracername, "TracerVersion", tracerversion, ... + "TracerSchema", tracerschema, "SpanKind", spankind, "Attributes", attrs); + + % run the example + [~] = beginTrace(at, 100); + + % perform test comparisons + results = readJsonResults(testCase); + verifyNumElements(testCase, results, 3); + + % check specified options in each span + for i = 1:numel(results) + verifyEqual(testCase, string(results{i}.resourceSpans.scopeSpans.scope.name), tracername); + verifyEqual(testCase, string(results{i}.resourceSpans.scopeSpans.scope.version), tracerversion); + verifyEqual(testCase, string(results{i}.resourceSpans.scopeSpans.schemaUrl), tracerschema); + verifyEqual(testCase, results{i}.resourceSpans.scopeSpans.spans.kind, 5); % SpanKind consumer + + % attributes + attrkeys = string({results{i}.resourceSpans.scopeSpans.spans.attributes.key}); + + for ii = 1:numel(attrnames) + attrnameii = attrnames(ii); + idxii = find(attrkeys == attrnameii); + verifyNotEmpty(testCase, idxii); + verifyEqual(testCase, results{i}.resourceSpans.scopeSpans.spans.attributes(idxii).value.doubleValue, ... + attrvalues(ii)); + end + end + end + + function testError(testCase) + % testError: handling error situation + + % set up AutoTrace + at = opentelemetry.autoinstrument.AutoTrace(@example1); + + % run the example with an invalid input, check for error + verifyError(testCase, @()beginTrace(at, "invalid"), "autotrace_examples:example1:generate_data:InvalidN"); + + % 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), "example1"); + + % check parent children relationship + verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.parentSpanId, results{2}.resourceSpans.scopeSpans.spans.spanId); + + % check error status + verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.code, 2); % error + verifyEmpty(testCase, fieldnames(results{2}.resourceSpans.scopeSpans.spans.status)); % ok, no error + end + + function testHandleError(testCase) + % testHandleError: directly call handleError method rather than using + % beginTrace method. This test should use example1_trycatch, which + % wraps a try-catch in the input function and calls handleError + % in the catch block. + + % set up AutoTrace, using example1_trycatch + at = opentelemetry.autoinstrument.AutoTrace(@example1_trycatch); + + % call example directly instead of calling beginTrace, and pass + % in an invalid input + verifyError(testCase, @()example1_trycatch(at, "invalid"), "autotrace_examples:example1:generate_data:InvalidN"); + + % 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), "example1_trycatch"); + + % check parent children relationship + verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.parentSpanId, results{2}.resourceSpans.scopeSpans.spans.spanId); + + % check error status + verifyEqual(testCase, results{1}.resourceSpans.scopeSpans.spans.status.code, 2); % error + verifyEmpty(testCase, fieldnames(results{2}.resourceSpans.scopeSpans.spans.status)); % ok, no error + end + + function testMultipleInstances(testCase) + % testMultipleInstances: multiple overlapped instances should + % return an error + + % set up AutoTrace + at = opentelemetry.autoinstrument.AutoTrace(@example1); %#ok + + % set up another identical instance, check for error + verifyError(testCase, @()opentelemetry.autoinstrument.AutoTrace(@example1), "opentelemetry:autoinstrument:AutoTrace:OverlappedInstances"); + end + + function testClearInstance(~) + % testClearInstance: clear an instance and recreate a new instance + + % create and instance and then clear + at = opentelemetry.autoinstrument.AutoTrace(@example1); %#ok + clear("at") + + % create a new instance should not result in any error + at = opentelemetry.autoinstrument.AutoTrace(@example1); %#ok + end + + function testInvalidInputFunction(testCase) + % testInvalidInputFunction: negative test for invalid input + + % anonymous function + verifyError(testCase, @()opentelemetry.autoinstrument.AutoTrace(@()example1), "opentelemetry:autoinstrument:AutoTrace:AnonymousFunction"); + + % builtin function + verifyError(testCase, @()opentelemetry.autoinstrument.AutoTrace(@uplus), "opentelemetry:autoinstrument:AutoTrace:BuiltinFunction"); + + % nonexistent function + verifyError(testCase, @()opentelemetry.autoinstrument.AutoTrace(@bogus), "opentelemetry:autoinstrument:AutoTrace:InvalidMFile"); + end + end +end \ No newline at end of file