@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.
- package/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- 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
|
+
|