feat(telemetry): options + shared OTel Resource

This commit is contained in:
Joseph Doherty
2026-06-01 07:30:54 -04:00
parent a1c3d5ec81
commit 645388b1f1
3 changed files with 179 additions and 0 deletions
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using OpenTelemetry.Resources;
namespace ZB.MOM.WW.Telemetry;
/// <summary>
/// Builds the shared OpenTelemetry ResourceBuilder from <see cref="ZbTelemetryOptions"/>.
/// Used internally by <c>AddZbTelemetry</c> so metrics, traces, and logs carry an identical
/// Resource. Exposed for tests and custom pipelines.
/// </summary>
public static class ZbResource
{
/// <summary>
/// Returns a <see cref="ResourceBuilder"/> pre-populated with <c>service.name</c>,
/// <c>service.namespace</c>, <c>service.version</c>, <c>site.id</c>, <c>node.role</c>, and
/// <c>host.name</c> (always <see cref="System.Environment.MachineName"/>). Attributes with
/// null values are omitted from the Resource.
/// </summary>
/// <param name="options">The telemetry options describing the service identity.</param>
public static ResourceBuilder Build(ZbTelemetryOptions options) =>
Configure(ResourceBuilder.CreateDefault(), options);
/// <summary>
/// Applies the shared ZB.MOM.WW Resource attributes to an existing <see cref="ResourceBuilder"/>.
/// Internal seam so the <c>AddZbTelemetry</c> pipeline produces a Resource identical to
/// <see cref="Build"/>.
/// </summary>
internal static ResourceBuilder Configure(ResourceBuilder builder, ZbTelemetryOptions options)
{
builder.AddService(
serviceName: options.ServiceName,
serviceNamespace: options.ServiceNamespace,
serviceVersion: options.ServiceVersion);
var attributes = new List<KeyValuePair<string, object>>
{
new("host.name", System.Environment.MachineName),
};
if (!string.IsNullOrEmpty(options.SiteId))
{
attributes.Add(new("site.id", options.SiteId));
}
if (!string.IsNullOrEmpty(options.NodeRole))
{
attributes.Add(new("node.role", options.NodeRole));
}
builder.AddAttributes(attributes);
return builder;
}
}
@@ -0,0 +1,76 @@
namespace ZB.MOM.WW.Telemetry;
/// <summary>
/// Selects how instrumentation data is exported.
/// </summary>
public enum ZbExporter
{
/// <summary>
/// Prometheus scrape endpoint (default). Call <c>app.MapZbMetrics()</c> to mount <c>/metrics</c>.
/// </summary>
Prometheus,
/// <summary>
/// OTLP gRPC export. Set <see cref="ZbTelemetryOptions.OtlpEndpoint"/>
/// (e.g. <c>"http://collector:4317"</c>).
/// </summary>
Otlp,
}
/// <summary>
/// Options for <c>AddZbTelemetry</c>. All properties feed the shared OpenTelemetry Resource.
/// </summary>
public sealed class ZbTelemetryOptions
{
/// <summary>
/// Required. Short lower-case app identifier — e.g. <c>"otopcua"</c>, <c>"mxgateway"</c>,
/// <c>"scadabridge"</c>. Populates OTel Resource <c>service.name</c>.
/// </summary>
public string ServiceName { get; set; } = "";
/// <summary>
/// Fleet-wide namespace. Default <c>"ZB.MOM.WW"</c>. Do not override per-app.
/// Populates OTel Resource <c>service.namespace</c>.
/// </summary>
public string ServiceNamespace { get; set; } = "ZB.MOM.WW";
/// <summary>
/// Optional. Populate from <c>AssemblyInformationalVersion</c>.
/// Populates OTel Resource <c>service.version</c>.
/// </summary>
public string? ServiceVersion { get; set; }
/// <summary>
/// Optional. Physical or logical site identifier.
/// Populates OTel Resource <c>site.id</c>.
/// </summary>
public string? SiteId { get; set; }
/// <summary>
/// Optional. Node function: <c>"central"</c>, <c>"site"</c>, <c>"hub"</c>, <c>"standalone"</c>.
/// Populates OTel Resource <c>node.role</c>.
/// </summary>
public string? NodeRole { get; set; }
/// <summary>
/// App-specific Meter names to register with the OTel MeterProvider. Standard instrumentation
/// meters are added automatically (ASP.NET Core, HttpClient, runtime, process).
/// </summary>
public string[] Meters { get; set; } = [];
/// <summary>
/// App-specific ActivitySource names to register with the OTel TracerProvider.
/// </summary>
public string[] ActivitySources { get; set; } = [];
/// <summary>
/// Export path. Default Prometheus; use <see cref="ZbExporter.Otlp"/> for a real collector.
/// </summary>
public ZbExporter Exporter { get; set; } = ZbExporter.Prometheus;
/// <summary>
/// Required when <see cref="Exporter"/> = <see cref="ZbExporter.Otlp"/>.
/// OTLP gRPC endpoint, e.g. <c>"http://collector:4317"</c>.
/// </summary>
public string? OtlpEndpoint { get; set; }
}
@@ -0,0 +1,50 @@
using OpenTelemetry.Resources;
using ZB.MOM.WW.Telemetry;
namespace ZB.MOM.WW.Telemetry.Tests;
public sealed class ZbResourceTests
{
[Fact]
public void Build_PopulatesAllResourceAttributes()
{
var options = new ZbTelemetryOptions
{
ServiceName = "otopcua",
ServiceNamespace = "ZB.MOM.WW",
ServiceVersion = "1.2.3",
SiteId = "site-7",
NodeRole = "central",
};
var resource = ZbResource.Build(options).Build();
var attributes = resource.Attributes.ToDictionary(a => a.Key, a => a.Value);
Assert.Equal("otopcua", attributes["service.name"]);
Assert.Equal("ZB.MOM.WW", attributes["service.namespace"]);
Assert.Equal("1.2.3", attributes["service.version"]);
Assert.Equal("site-7", attributes["site.id"]);
Assert.Equal("central", attributes["node.role"]);
Assert.Equal(Environment.MachineName, attributes["host.name"]);
}
[Fact]
public void Build_OmitsOptionalAttributes_WhenNull()
{
var options = new ZbTelemetryOptions
{
ServiceName = "mxgateway",
// ServiceVersion / SiteId / NodeRole left null
};
var resource = ZbResource.Build(options).Build();
var keys = resource.Attributes.Select(a => a.Key).ToHashSet();
Assert.Contains("service.name", keys);
Assert.Contains("service.namespace", keys);
Assert.Contains("host.name", keys);
Assert.DoesNotContain("service.version", keys);
Assert.DoesNotContain("site.id", keys);
Assert.DoesNotContain("node.role", keys);
}
}