@uknows/setup 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. package/package.json +3 -8
package/lib/git.mjs CHANGED
@@ -45,3 +45,56 @@ function onlyIgnored(root, dir, ignore, budget = { left: 2000 }) {
45
45
  }
46
46
  return true
47
47
  }
48
+
49
+ /**
50
+ * True when git ignores this path: .gitignore files, .git/info/exclude or the global excludes. Then git add -A leaves
51
+ * the file out of the commit. False when git does not ignore it, or cannot tell (no git, not a repo).
52
+ * @param {string} root absolute path of the folder setup works in
53
+ * @param {string} path relative to root, '/' separated
54
+ */
55
+ export function gitIgnored(root, path) {
56
+ const r = spawnSync('git', ['-C', root, 'check-ignore', '-q', '--', path], { encoding: 'utf8' })
57
+ return !r.error && r.status === 0
58
+ }
59
+
60
+ /**
61
+ * The GitHub repo of the origin remote, as owner/name, for the gh commands of the key steps. null when there is no
62
+ * git, no origin, or the origin is not on github.com. Reads the git config only.
63
+ * @param {string} root absolute path of the folder setup works in
64
+ * @returns {string|null}
65
+ */
66
+ export function gitRemote(root) {
67
+ const r = spawnSync('git', ['-C', root, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8' })
68
+ if (r.error || r.status !== 0) return null
69
+ return githubSlug(r.stdout.trim())
70
+ }
71
+
72
+ /**
73
+ * Where the origin remote is hosted: 'github', 'gitlab', 'azure-devops', 'bitbucket' or 'other'. null when there is no
74
+ * git or no origin. Reads the git config only. The update bot that setup adds depends on it (updates.mjs).
75
+ * @param {string} root absolute path of the folder setup works in
76
+ * @returns {'github'|'gitlab'|'azure-devops'|'bitbucket'|'other'|null}
77
+ */
78
+ export function gitHost(root) {
79
+ const r = spawnSync('git', ['-C', root, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8' })
80
+ if (r.error || r.status !== 0 || !r.stdout.trim()) return null
81
+ return hostOfRemote(r.stdout.trim())
82
+ }
83
+
84
+ /** The host of a remote address, by its host name: GitHub (also GitHub Enterprise), GitLab, Azure DevOps, Bitbucket. */
85
+ export function hostOfRemote(url) {
86
+ const m = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(String(url ?? '').trim())
87
+ const host = (m?.[1] ?? '').toLowerCase()
88
+ if (!host) return 'other'
89
+ if (/(^|\.)github\.com$|github/.test(host)) return 'github'
90
+ if (/gitlab/.test(host)) return 'gitlab'
91
+ if (/(^|\.)dev\.azure\.com$|\.visualstudio\.com$|^ssh\.dev\.azure\.com$/.test(host)) return 'azure-devops'
92
+ if (/bitbucket/.test(host)) return 'bitbucket'
93
+ return 'other'
94
+ }
95
+
96
+ /** owner/name from a GitHub remote: https://github.com/o/n(.git), git@github.com:o/n(.git) or ssh://git@github.com/o/n. */
97
+ export function githubSlug(url) {
98
+ const m = /^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/git@|git@)github\.com[/:]([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(url ?? '')
99
+ return m ? `${m[1]}/${m[2]}` : null
100
+ }
@@ -0,0 +1,118 @@
1
+ // Added by npx @uknows/setup. UKNOWS for Azure Functions (.NET isolated worker), for a function app that keeps
2
+ // Application Insights. A server span for each call of a function, as a child of the trace that the Functions host passes
3
+ // on (so UKNOWS and Application Insights share the trace id), and the logs of the function with that span, over OTLP.
4
+ // The settings are the OTEL_* app settings of the function app. Not Microsoft.Azure.Functions.Worker.OpenTelemetry:
5
+ // with it, the logs of the functions stopped reaching Application Insights (measured 22 Oct 2026). Program.cs calls
6
+ // UseMiddleware<UknowsOpenTelemetry>() and UknowsOpenTelemetry.AddTo(services). To undo it, run npx @uknows/setup remove.
7
+ // Tested with Microsoft.Azure.Functions.Worker 2.52 on .NET 10 (Functions host 4.1055).
8
+ using System.Diagnostics;
9
+ using System.Reflection;
10
+ using Microsoft.Azure.Functions.Worker;
11
+ using Microsoft.Azure.Functions.Worker.Http;
12
+ using Microsoft.Azure.Functions.Worker.Middleware;
13
+ using Microsoft.Extensions.DependencyInjection;
14
+ using OpenTelemetry;
15
+ using OpenTelemetry.Resources;
16
+ using OpenTelemetry.Trace;
17
+
18
+ namespace Uknows.AzureFunctions;
19
+
20
+ public sealed class UknowsOpenTelemetry : IFunctionsWorkerMiddleware
21
+ {
22
+ public const string SourceName = "Uknows.AzureFunctions";
23
+ private static readonly ActivitySource Source = new(SourceName);
24
+
25
+ /// <summary>The host in Application Insights mode passes a trace context that is not sampled: follow its trace id, but record.</summary>
26
+ private static readonly Sampler Sampler = new ParentBasedSampler(new AlwaysOnSampler(),
27
+ remoteParentSampled: new AlwaysOnSampler(), remoteParentNotSampled: new AlwaysOnSampler(),
28
+ localParentSampled: new AlwaysOnSampler(), localParentNotSampled: new AlwaysOnSampler());
29
+
30
+ /// <summary>The OpenTelemetry SDK: the spans of this middleware and the logs, over OTLP.</summary>
31
+ public static void AddTo(IServiceCollection services)
32
+ {
33
+ var otel = services.AddOpenTelemetry()
34
+ .ConfigureResource(r => r.AddAttributes(Azure()).AddEnvironmentVariableDetector())
35
+ .WithTracing(tracing => tracing.AddSource(SourceName).SetSampler(Sampler));
36
+ if (Environment.GetEnvironmentVariable("OTEL_LOGS_EXPORTER") != "none") otel.WithLogging();
37
+ otel.UseOtlpExporter();
38
+ }
39
+
40
+ /// <summary>The function app in Azure, from the variables that App Service sets.</summary>
41
+ private static IEnumerable<KeyValuePair<string, object>> Azure()
42
+ {
43
+ var env = (string name) => Environment.GetEnvironmentVariable(name);
44
+ var sub = (env("WEBSITE_OWNER_NAME") ?? "").Split('+')[0];
45
+ var (group, site, region) = (env("WEBSITE_RESOURCE_GROUP"), env("WEBSITE_SITE_NAME"), env("REGION_NAME"));
46
+ yield return new("cloud.provider", "azure");
47
+ yield return new("cloud.platform", "azure_functions");
48
+ if (!string.IsNullOrEmpty(region)) yield return new("cloud.region", region);
49
+ if (sub.Length > 0 && !string.IsNullOrEmpty(group) && !string.IsNullOrEmpty(site))
50
+ yield return new("cloud.resource_id", $"/subscriptions/{sub}/resourceGroups/{group}/providers/Microsoft.Web/sites/{site}");
51
+ }
52
+
53
+ public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
54
+ {
55
+ ActivityContext.TryParse(context.TraceContext?.TraceParent, context.TraceContext?.TraceState, isRemote: true, out var parent);
56
+ var http = context.FunctionDefinition.InputBindings.Values.Any(b => b.Type.Equals("httpTrigger", StringComparison.OrdinalIgnoreCase));
57
+ var request = http ? await context.GetHttpRequestDataAsync() : null;
58
+ var route = http ? $"/api/{RouteOf(context.FunctionDefinition) ?? context.FunctionDefinition.Name}" : null;
59
+ using var activity = Source.StartActivity(http ? $"{request?.Method} {route}" : context.FunctionDefinition.Name,
60
+ http ? ActivityKind.Server : ActivityKind.Internal, parent);
61
+ activity?.SetTag("faas.name", context.FunctionDefinition.Name);
62
+ activity?.SetTag("faas.invocation_id", context.InvocationId);
63
+ activity?.SetTag("faas.trigger", http ? "http" : "other");
64
+ if (http)
65
+ {
66
+ activity?.SetTag("http.request.method", request?.Method);
67
+ activity?.SetTag("http.route", route);
68
+ }
69
+ try
70
+ {
71
+ await next(context);
72
+ var status = http ? (int?)context.GetHttpResponseData()?.StatusCode : null;
73
+ if (status is not null)
74
+ {
75
+ activity?.SetTag("http.response.status_code", status);
76
+ if (status >= 500) activity?.SetStatus(ActivityStatusCode.Error);
77
+ }
78
+ }
79
+ catch (Exception ex)
80
+ {
81
+ // The type of the exception and the file and line of each frame, never the message.
82
+ activity?.SetStatus(ActivityStatusCode.Error);
83
+ if (http) activity?.SetTag("http.response.status_code", 500);
84
+ activity?.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection
85
+ {
86
+ { "exception.type", ex.GetType().FullName },
87
+ { "exception.stacktrace", Frames(ex) },
88
+ }));
89
+ throw;
90
+ }
91
+ }
92
+
93
+ /// <summary>The frames of the exception: method, file and line, as .NET prints them, without the message.</summary>
94
+ private static string Frames(Exception ex) => string.Join("\n", (new StackTrace(ex, fNeedFileInfo: true).GetFrames() ?? [])
95
+ .Select(f => $" at {f.GetMethod()?.DeclaringType?.FullName}.{f.GetMethod()?.Name}" +
96
+ (f.GetFileName() is { } file ? $" in {file}:line {f.GetFileLineNumber()}" : "")));
97
+
98
+ private static readonly Dictionary<string, string?> Routes = new();
99
+
100
+ /// <summary>The Route of the HttpTrigger attribute of the function's method.</summary>
101
+ private static string? RouteOf(FunctionDefinition f)
102
+ {
103
+ lock (Routes)
104
+ {
105
+ if (Routes.TryGetValue(f.Id, out var known)) return known;
106
+ string? route = null;
107
+ var dot = f.EntryPoint.LastIndexOf('.');
108
+ var type = dot > 0 ? Type.GetType($"{f.EntryPoint[..dot]}, {Path.GetFileNameWithoutExtension(f.PathToAssembly)}") : null;
109
+ var method = type?.GetMethods().FirstOrDefault(m => m.Name == f.EntryPoint[(dot + 1)..]);
110
+ foreach (var p in method?.GetParameters() ?? [])
111
+ {
112
+ var attribute = p.GetCustomAttributes().FirstOrDefault(a => a.GetType().Name == "HttpTriggerAttribute");
113
+ if (attribute is not null) route = attribute.GetType().GetProperty("Route")?.GetValue(attribute) as string;
114
+ }
115
+ return Routes[f.Id] = route;
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,28 @@
1
+ // The UKNOWS hooks for Azure Functions (stacks/functions-hooks.mjs): what each language adds, at the versions measured
2
+ // on the bench. A module of its own with no imports, so the manifest check can read the package names too.
3
+ /** What each language adds, at the versions measured on the bench, and the versions that the bench ran. */
4
+ export const HOOKS = {
5
+ node: {
6
+ file: 'uknows-otel.cjs',
7
+ packages: [['@opentelemetry/api', '1.9.1'], ['@opentelemetry/api-logs', '0.223.0'], ['@opentelemetry/exporter-logs-otlp-http', '0.223.0'],
8
+ ['@opentelemetry/exporter-trace-otlp-http', '0.223.0'], ['@opentelemetry/resources', '2.12.0'], ['@opentelemetry/sdk-logs', '0.223.0'],
9
+ ['@opentelemetry/sdk-trace-node', '2.12.0']],
10
+ tested: [['@azure/functions', '4.16']],
11
+ },
12
+ python: {
13
+ file: 'uknows_otel.py',
14
+ packages: [['opentelemetry-api', '1.45.1'], ['opentelemetry-sdk', '1.45.1'], ['opentelemetry-exporter-otlp-proto-http', '1.45.1']],
15
+ settings: [{ name: 'PYTHON_ENABLE_WORKER_EXTENSIONS', value: '1' }],
16
+ tested: [['azure-functions', '1.25']],
17
+ },
18
+ dotnet: {
19
+ file: 'UknowsOpenTelemetry.cs',
20
+ packages: [['OpenTelemetry.Extensions.Hosting', '1.19.1'], ['OpenTelemetry.Exporter.OpenTelemetryProtocol', '1.19.1']],
21
+ tested: [['Microsoft.Azure.Functions.Worker', '2.52']],
22
+ },
23
+ }
24
+ /** The host version and runtimes of the bench (shown with the tested versions). */
25
+ export const TESTED_ON = 'Functions host 4.1055, Node.js 22, Python 3.12, .NET 10, Linux'
26
+ /** The files of the hooks, by name: a pull request that has one of them adds the hooks. */
27
+ export const HOOK_FILES = Object.values(HOOKS).map((h) => h.file)
28
+
@@ -0,0 +1,82 @@
1
+ // Added by npx @uknows/setup. UKNOWS for Azure Functions (Node.js), for a function app that keeps Application Insights.
2
+ // A server span for each call of a function, as a child of the trace that the Functions host passes on (so UKNOWS and
3
+ // Application Insights share the trace id), and the logs of the function with that span, over OTLP. The settings are
4
+ // the OTEL_* app settings of the function app. This file does not tell the host that the worker sends logs, so the host
5
+ // keeps sending them to Application Insights exactly as before. To undo it, run npx @uknows/setup remove.
6
+ // Tested with @azure/functions 4.16 on Node.js 22 (Functions host 4.1055).
7
+ const { app } = require('@azure/functions')
8
+ const { SpanKind, SpanStatusCode, context, propagation, trace } = require('@opentelemetry/api')
9
+ const { SeverityNumber } = require('@opentelemetry/api-logs')
10
+ const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http')
11
+ const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http')
12
+ const { defaultResource, detectResources, envDetector, resourceFromAttributes } = require('@opentelemetry/resources')
13
+ const { BatchLogRecordProcessor, LoggerProvider } = require('@opentelemetry/sdk-logs')
14
+ const { AlwaysOnSampler, BatchSpanProcessor, NodeTracerProvider, ParentBasedSampler } = require('@opentelemetry/sdk-trace-node')
15
+
16
+ /** The function app in Azure, from the variables that App Service sets. */
17
+ function azure() {
18
+ const env = process.env
19
+ const sub = (env.WEBSITE_OWNER_NAME ?? '').split('+')[0]
20
+ const group = env.WEBSITE_RESOURCE_GROUP, site = env.WEBSITE_SITE_NAME
21
+ return { 'cloud.provider': 'azure', 'cloud.platform': 'azure_functions', ...(env.REGION_NAME ? { 'cloud.region': env.REGION_NAME } : {}),
22
+ ...(sub && group && site ? { 'cloud.resource_id': `/subscriptions/${sub}/resourceGroups/${group}/providers/Microsoft.Web/sites/${site}` } : {}) }
23
+ }
24
+
25
+ const resource = defaultResource().merge(resourceFromAttributes(azure())).merge(detectResources({ detectors: [envDetector] }))
26
+ // The host in Application Insights mode passes a trace context that is not sampled: follow its trace id, but record.
27
+ const sampler = new ParentBasedSampler({ root: new AlwaysOnSampler(), remoteParentNotSampled: new AlwaysOnSampler() })
28
+ const tracerProvider = new NodeTracerProvider({ resource, sampler, spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())] })
29
+ tracerProvider.register()
30
+ const tracer = tracerProvider.getTracer('uknows-azure-functions')
31
+ const logs = (process.env.OTEL_LOGS_EXPORTER ?? 'otlp') !== 'none'
32
+ const logger = logs ? new LoggerProvider({ resource, processors: [new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() })] }).getLogger('uknows-azure-functions') : null
33
+ const SEVERITY = { trace: SeverityNumber.TRACE, debug: SeverityNumber.DEBUG, information: SeverityNumber.INFO, warning: SeverityNumber.WARN,
34
+ error: SeverityNumber.ERROR, critical: SeverityNumber.FATAL }
35
+ /** The span of each call that runs, by invocation id. */
36
+ const spans = new Map()
37
+
38
+ app.hook.preInvocation((ctx) => {
39
+ const inv = ctx.invocationContext
40
+ const tc = inv.traceContext
41
+ const parent = tc?.traceParent ? propagation.extract(context.active(), { traceparent: tc.traceParent, tracestate: tc.traceState }) : context.active()
42
+ const trigger = inv.options?.trigger
43
+ const http = trigger?.type === 'httpTrigger'
44
+ const req = http ? ctx.inputs[0] : null
45
+ const route = http ? `/api/${trigger.route ?? inv.functionName}` : null
46
+ const span = tracer.startSpan(http ? `${req.method} ${route}` : inv.functionName, {
47
+ kind: http ? SpanKind.SERVER : SpanKind.INTERNAL,
48
+ attributes: { 'faas.name': inv.functionName, 'faas.invocation_id': inv.invocationId, 'faas.trigger': http ? 'http' : 'other',
49
+ ...(http ? { 'http.request.method': req.method, 'http.route': route } : {}) },
50
+ }, parent)
51
+ spans.set(inv.invocationId, span)
52
+ ctx.functionHandler = context.bind(trace.setSpan(parent, span), ctx.functionHandler)
53
+ })
54
+
55
+ app.hook.postInvocation((ctx) => {
56
+ const id = ctx.invocationContext.invocationId
57
+ const span = spans.get(id)
58
+ if (!span) return
59
+ spans.delete(id)
60
+ const status = ctx.error ? 500 : Number(ctx.result?.status ?? 200)
61
+ if (ctx.invocationContext.options?.trigger?.type === 'httpTrigger') span.setAttribute('http.response.status_code', status)
62
+ // The type of the error and the file and line of each frame, never the message.
63
+ if (ctx.error) span.addEvent('exception', { 'exception.type': typeName(ctx.error), 'exception.stacktrace': frames(ctx.error) })
64
+ if (ctx.error || status >= 500) span.setStatus({ code: SpanStatusCode.ERROR })
65
+ span.end()
66
+ })
67
+
68
+ /** The class name of an error, or the type of a thrown value that is not an Error. */
69
+ function typeName(error) {
70
+ return error instanceof Error ? (error.constructor?.name || error.name || 'Error') : typeof error
71
+ }
72
+ /** The frames of a stack (" at fn (file:line:column)"), without its first line, which holds the message. */
73
+ function frames(error) {
74
+ return String(error?.stack ?? '').split('\n').filter((line) => /^\s+at /.test(line)).join('\n')
75
+ }
76
+
77
+ app.hook.log((ctx) => {
78
+ if (!logger || ctx.category !== 'user') return
79
+ const span = spans.get(ctx.invocationContext?.invocationId)
80
+ logger.emit({ body: ctx.message, severityNumber: SEVERITY[ctx.level] ?? SeverityNumber.INFO, severityText: ctx.level,
81
+ context: span ? trace.setSpan(context.active(), span) : context.active() })
82
+ })
@@ -0,0 +1,179 @@
1
+ """Added by npx @uknows/setup. UKNOWS for Azure Functions (Python), for a function app that keeps Application Insights.
2
+
3
+ A server span for each call of a function, as a child of the trace that the Functions host passes on (so UKNOWS and
4
+ Application Insights share the trace id), and the logs of the function with that span, over OTLP. The settings are the
5
+ OTEL_* app settings of the function app, and PYTHON_ENABLE_WORKER_EXTENSIONS=1. To undo it, run npx @uknows/setup remove.
6
+
7
+ A worker extension of azure-functions (AppExtensionBase): the worker runs its hooks before and after each call, in the
8
+ thread (or task) of the call. It does not set PYTHON_ENABLE_OPENTELEMETRY, so the host keeps sending the logs to
9
+ Application Insights exactly as before. The worker skips the after-hook when the function raises, so instrument(app)
10
+ also wraps each function: a call that raises ends its span with status error and an exception event. The event has the
11
+ exception type and the file, line and function of each frame, never the message, the source lines or local values.
12
+ Tested with azure-functions 1.25 on Python 3.12 (Functions host 4.1055).
13
+ """
14
+ import contextvars
15
+ import functools
16
+ import inspect
17
+ import logging
18
+ import os
19
+ import traceback
20
+
21
+ import azure.functions as func
22
+ from opentelemetry import _logs, context, trace
23
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
24
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
25
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
26
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
27
+ from opentelemetry.sdk.resources import Resource
28
+ from opentelemetry.sdk.trace import TracerProvider
29
+ from opentelemetry.sdk.trace.sampling import ALWAYS_ON, ParentBased
30
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
31
+ from opentelemetry.trace import SpanKind, Status, StatusCode
32
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
33
+
34
+ def _azure():
35
+ """The function app in Azure, from the variables that App Service sets."""
36
+ env = os.environ
37
+ sub = env.get("WEBSITE_OWNER_NAME", "").split("+")[0]
38
+ group, site = env.get("WEBSITE_RESOURCE_GROUP"), env.get("WEBSITE_SITE_NAME")
39
+ out = {"cloud.provider": "azure", "cloud.platform": "azure_functions"}
40
+ if env.get("REGION_NAME"):
41
+ out["cloud.region"] = env["REGION_NAME"]
42
+ if sub and group and site:
43
+ out["cloud.resource_id"] = f"/subscriptions/{sub}/resourceGroups/{group}/providers/Microsoft.Web/sites/{site}"
44
+ return out
45
+
46
+
47
+ # OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME win over the Azure attributes.
48
+ _resource = Resource.create(_azure()).merge(Resource.create())
49
+ # The host of a function app in App Insights mode passes a trace context that is not sampled, so a span of UKNOWS
50
+ # follows that trace id but is always recorded.
51
+ _tracer_provider = TracerProvider(resource=_resource, sampler=ParentBased(root=ALWAYS_ON, remote_parent_not_sampled=ALWAYS_ON))
52
+ _tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
53
+ trace.set_tracer_provider(_tracer_provider)
54
+ if os.environ.get("OTEL_LOGS_EXPORTER", "otlp") != "none":
55
+ _logger_provider = LoggerProvider(resource=_resource)
56
+ _logger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
57
+ _logs.set_logger_provider(_logger_provider)
58
+ # The logs of the app: the root logger, at INFO (the host's default), not the worker's own logs.
59
+ _handler = LoggingHandler(level=logging.INFO, logger_provider=_logger_provider)
60
+ _handler.addFilter(lambda record: not record.name.startswith("azure_functions_worker"))
61
+ logging.getLogger().addHandler(_handler)
62
+
63
+ _tracer = trace.get_tracer("uknows-azure-functions")
64
+ _propagator = TraceContextTextMapPropagator()
65
+ # The span of the call that runs, and its context token: a context variable, so async calls on one thread stay apart.
66
+ _current = contextvars.ContextVar("uknows_span", default=None)
67
+
68
+
69
+ class UknowsOpenTelemetry(func.AppExtensionBase):
70
+ """Opens a span before each call and closes it after."""
71
+
72
+ @classmethod
73
+ def init(cls):
74
+ pass
75
+
76
+ @classmethod
77
+ def configure(cls, *args, **kwargs):
78
+ pass
79
+
80
+ @classmethod
81
+ def pre_invocation_app_level(cls, logger, context_, func_args, *args, **kwargs):
82
+ tc = getattr(context_, "trace_context", None)
83
+ parent = _propagator.extract({"traceparent": tc.trace_parent, "tracestate": tc.trace_state}) if tc and tc.trace_parent else None
84
+ req = next((a for a in (func_args or {}).values() if isinstance(a, func.HttpRequest)), None)
85
+ if req is not None:
86
+ name = f"{req.method} {_route_of(context_.function_name)}"
87
+ attributes = {"http.request.method": req.method, "http.route": _route_of(context_.function_name)}
88
+ kind = SpanKind.SERVER
89
+ else:
90
+ name, attributes, kind = context_.function_name, {}, SpanKind.INTERNAL
91
+ attributes.update({"faas.name": context_.function_name, "faas.invocation_id": context_.invocation_id,
92
+ "faas.trigger": "http" if req is not None else "other"})
93
+ span = _tracer.start_span(name, context=parent, kind=kind, attributes=attributes)
94
+ token = context.attach(trace.set_span_in_context(span, parent))
95
+ _current.set((span, token, req is not None))
96
+
97
+ @classmethod
98
+ def post_invocation_app_level(cls, logger, context_, func_args, func_ret, *args, **kwargs):
99
+ status = getattr(func_ret, "status_code", None)
100
+ _end(int(status) if status is not None else None)
101
+
102
+
103
+ def _end(status=None, error=None):
104
+ """Ends the span of the call that runs: with its status code, or as failed with the exception event."""
105
+ current = _current.get()
106
+ if current is None:
107
+ return
108
+ span, token, http = current
109
+ _current.set(None)
110
+ if error is not None:
111
+ span.add_event("exception", {"exception.type": _type_name(error), "exception.stacktrace": _frames(error)})
112
+ span.set_status(Status(StatusCode.ERROR))
113
+ status = 500 if http else None
114
+ if status is not None:
115
+ span.set_attribute("http.response.status_code", status)
116
+ if status >= 500:
117
+ span.set_status(Status(StatusCode.ERROR))
118
+ span.end()
119
+ context.detach(token)
120
+
121
+
122
+ def _type_name(error):
123
+ t = type(error)
124
+ return t.__qualname__ if t.__module__ == "builtins" else f"{t.__module__}.{t.__qualname__}"
125
+
126
+
127
+ def _frames(error):
128
+ """The file, line and function of each frame, as a traceback prints them, without the source lines."""
129
+ frames = [f for f in traceback.extract_tb(error.__traceback__) if f.filename != __file__]
130
+ return "\n".join(f' File "{f.filename}", line {f.lineno}, in {f.name}' for f in frames)
131
+
132
+
133
+ def _wrap(fn):
134
+ """The function, with its span ended when it raises (the worker then skips the after-hook)."""
135
+ if inspect.iscoroutinefunction(fn):
136
+ @functools.wraps(fn)
137
+ async def run(*args, **kwargs):
138
+ try:
139
+ return await fn(*args, **kwargs)
140
+ except BaseException as error:
141
+ _end(error=error)
142
+ raise
143
+ else:
144
+ @functools.wraps(fn)
145
+ def run(*args, **kwargs):
146
+ try:
147
+ return fn(*args, **kwargs)
148
+ except BaseException as error:
149
+ _end(error=error)
150
+ raise
151
+ return run
152
+
153
+
154
+ _APPS = []
155
+ _ROUTES = {}
156
+
157
+
158
+ def _route_of(function_name):
159
+ """/api/<route> of the function, read from the app on first use (app.get_functions() may run only once: the worker
160
+ calls it, and a second call fails its check of unique names)."""
161
+ if function_name not in _ROUTES:
162
+ for app in _APPS:
163
+ for fb in getattr(app, "_function_builders", []):
164
+ f = getattr(fb, "_function", None)
165
+ route = getattr(f.get_trigger(), "route", None) if f is not None and f.get_trigger() is not None else None
166
+ if f is not None and route is not None:
167
+ _ROUTES[f.get_function_name()] = f"/api/{route}"
168
+ return _ROUTES.get(function_name, f"/api/{function_name}")
169
+
170
+
171
+ def instrument(app):
172
+ """Call at the end of function_app.py: the routes name the spans, and each function ends its span when it raises."""
173
+ _APPS.append(app)
174
+ for fb in getattr(app, "_function_builders", []):
175
+ f = getattr(fb, "_function", None)
176
+ if f is not None and callable(getattr(f, "_func", None)) and not getattr(f._func, "_uknows", False):
177
+ f._func = _wrap(f._func)
178
+ f._func._uknows = True
179
+