diff --git a/OpenTelemetryMetricsTraces.md b/OpenTelemetryMetricsTraces.md
new file mode 100644
index 0000000..70d1473
--- /dev/null
+++ b/OpenTelemetryMetricsTraces.md
@@ -0,0 +1,199 @@
+# OpenTelemetry Metrics and Traces for OpenCertServer
+
+This document lists relevant OpenTelemetry metrics and traces for OpenCertServer based on implemented RFCs (EST RFC 7030/8951/9908, ACME RFC 8555, OCSP RFC 6960, CRL RFC 5280) and codebase analysis.
+
+## Naming Strategy
+- **Metrics**: `opencertserver.{protocol}.{operation}.{type}` where `type` is `requests` (counter), `successes` (counter), `failures` (counter), `duration` (histogram in seconds), `active` (gauge for ongoing operations).
+- **Traces**: Span name `opencertserver.{protocol}.{operation}`, with sub-spans for internal steps like validation, signing, persistence.
+- Protocols: `est`, `acme`, `ocsp`, `crl`, `ca` (for CA endpoints), `cli` (for CLI operations).
+
+## EST (RFC 7030)
+
+### Metrics
+- `opencertserver.est.cacerts.requests`: Counter for /cacerts requests.
+- `opencertserver.est.cacerts.successes`: Counter for successful /cacerts responses.
+- `opencertserver.est.cacerts.failures`: Counter for failed /cacerts responses (e.g., internal errors).
+- `opencertserver.est.cacerts.duration`: Histogram for request duration.
+- `opencertserver.est.simpleenroll.requests`: Counter for /simpleenroll requests.
+- `opencertserver.est.simpleenroll.successes`: Counter for successful enrollments.
+- `opencertserver.est.simpleenroll.failures`: Counter for enrollment failures (e.g., invalid CSR, auth failure).
+- `opencertserver.est.simpleenroll.duration`: Histogram for enrollment duration.
+- `opencertserver.est.simplereenroll.requests`: Counter for /simplereenroll requests.
+- `opencertserver.est.simplereenroll.successes`: Counter for successful re-enrollments.
+- `opencertserver.est.simplereenroll.failures`: Counter for re-enrollment failures.
+- `opencertserver.est.simplereenroll.duration`: Histogram for re-enrollment duration.
+- `opencertserver.est.csrattrs.requests`: Counter for /csrattrs requests.
+- `opencertserver.est.csrattrs.successes`: Counter for successful /csrattrs responses.
+- `opencertserver.est.csrattrs.failures`: Counter for /csrattrs failures.
+- `opencertserver.est.csrattrs.duration`: Histogram for /csrattrs duration.
+- `opencertserver.est.serverkeygen.requests`: Counter for /serverkeygen requests.
+- `opencertserver.est.serverkeygen.successes`: Counter for successful key generations.
+- `opencertserver.est.serverkeygen.failures`: Counter for key generation failures.
+- `opencertserver.est.serverkeygen.duration`: Histogram for key generation duration.
+
+### Traces
+- `opencertserver.est.cacerts`: Span for /cacerts request, sub-spans for certificate chain retrieval and response encoding.
+- `opencertserver.est.simpleenroll`: Span for enrollment, sub-spans for CSR parsing, validation, signing, certificate issuance.
+- `opencertserver.est.simplereenroll`: Span for re-enrollment, sub-spans for authentication, CSR validation, signing.
+- `opencertserver.est.csrattrs`: Span for CSR attributes retrieval.
+- `opencertserver.est.serverkeygen`: Span for key generation, sub-spans for key pair creation, certificate signing.
+
+**Collection Location**: In `src/opencertserver.est.server/Handlers/` (e.g., `CaCertHandler.cs`, `SimpleEnrollHandler.cs`), wrap `Handle` methods with spans, record metrics at start/end.
+
+## ACME (RFC 8555)
+
+### Metrics
+- `opencertserver.acme.directory.requests`: Counter for /directory requests.
+- `opencertserver.acme.directory.successes`: Counter for successful directory responses.
+- `opencertserver.acme.directory.failures`: Counter for directory failures.
+- `opencertserver.acme.directory.duration`: Histogram for directory duration.
+- `opencertserver.acme.newnonce.requests`: Counter for /new-nonce requests.
+- `opencertserver.acme.newnonce.successes`: Counter for successful nonce responses.
+- `opencertserver.acme.newnonce.failures`: Counter for nonce failures.
+- `opencertserver.acme.newnonce.duration`: Histogram for nonce duration.
+- `opencertserver.acme.newaccount.requests`: Counter for /new-account requests.
+- `opencertserver.acme.newaccount.successes`: Counter for successful account creations.
+- `opencertserver.acme.newaccount.failures`: Counter for account creation failures (e.g., invalid JWS).
+- `opencertserver.acme.newaccount.duration`: Histogram for account creation duration.
+- `opencertserver.acme.neworder.requests`: Counter for /new-order requests.
+- `opencertserver.acme.neworder.successes`: Counter for successful order creations.
+- `opencertserver.acme.neworder.failures`: Counter for order creation failures.
+- `opencertserver.acme.neworder.duration`: Histogram for order creation duration.
+- `opencertserver.acme.orderfinalize.requests`: Counter for /order/{id}/finalize requests.
+- `opencertserver.acme.orderfinalize.successes`: Counter for successful finalizes.
+- `opencertserver.acme.orderfinalize.failures`: Counter for finalize failures.
+- `opencertserver.acme.orderfinalize.duration`: Histogram for finalize duration.
+- `opencertserver.acme.certificate.requests`: Counter for /order/{id}/certificate requests.
+- `opencertserver.acme.certificate.successes`: Counter for successful certificate downloads.
+- `opencertserver.acme.certificate.failures`: Counter for certificate download failures.
+- `opencertserver.acme.certificate.duration`: Histogram for certificate duration.
+- `opencertserver.acme.challengevalidation.requests`: Counter for challenge validation attempts.
+- `opencertserver.acme.challengevalidation.successes`: Counter for successful validations.
+- `opencertserver.acme.challengevalidation.failures`: Counter for validation failures.
+- `opencertserver.acme.challengevalidation.duration`: Histogram for validation duration.
+- `opencertserver.acme.challengevalidation.active`: Gauge for active pending challenges.
+- `opencertserver.acme.keychange.requests`: Counter for /key-change requests.
+- `opencertserver.acme.keychange.successes`: Counter for successful key rollovers.
+- `opencertserver.acme.keychange.failures`: Counter for key rollover failures.
+- `opencertserver.acme.keychange.duration`: Histogram for key rollover duration.
+- `opencertserver.acme.revoke.requests`: Counter for /revoke-cert requests.
+- `opencertserver.acme.revoke.successes`: Counter for successful revocations.
+- `opencertserver.acme.revoke.failures`: Counter for revocation failures.
+- `opencertserver.acme.revoke.duration`: Histogram for revocation duration.
+
+### Traces
+- `opencertserver.acme.directory`: Span for directory request.
+- `opencertserver.acme.newnonce`: Span for nonce request.
+- `opencertserver.acme.newaccount`: Span for account creation, sub-spans for JWS validation, account storage.
+- `opencertserver.acme.neworder`: Span for order creation, sub-spans for authorization setup.
+- `opencertserver.acme.orderfinalize`: Span for finalize, sub-spans for CSR validation, certificate issuance.
+- `opencertserver.acme.certificate`: Span for certificate download.
+- `opencertserver.acme.challengevalidation`: Span for validation, sub-spans for HTTP/DNS checks.
+- `opencertserver.acme.keychange`: Span for key rollover, sub-spans for signature verification.
+- `opencertserver.acme.revoke`: Span for revocation, sub-spans for certificate validation.
+
+**Collection Location**: In `src/opencertserver.acme.server/Endpoints/` (e.g., `AccountEndpoints.cs`, `OrderEndpoints.cs`), wrap endpoint methods with spans, record metrics.
+
+## OCSP (RFC 6960)
+
+### Metrics
+- `opencertserver.ocsp.request.requests`: Counter for OCSP requests.
+- `opencertserver.ocsp.request.successes`: Counter for successful responses (good/revoked/unknown).
+- `opencertserver.ocsp.request.failures`: Counter for failures (malformed, internalError, tryLater).
+- `opencertserver.ocsp.request.duration`: Histogram for request processing duration.
+
+### Traces
+- `opencertserver.ocsp.request`: Span for OCSP request, sub-spans for request parsing, certificate lookup, response signing.
+
+**Collection Location**: In `src/opencertserver.ca.server/Handlers/OcspHandler.cs`, wrap `Handle` method.
+
+## CRL (RFC 5280)
+
+### Metrics
+- `opencertserver.crl.request.requests`: Counter for CRL requests.
+- `opencertserver.crl.request.successes`: Counter for successful CRL responses.
+- `opencertserver.crl.request.failures`: Counter for CRL failures.
+- `opencertserver.crl.request.duration`: Histogram for request duration.
+- `opencertserver.crl.generation.requests`: Counter for CRL generation triggers.
+- `opencertserver.crl.generation.successes`: Counter for successful generations.
+- `opencertserver.crl.generation.failures`: Counter for generation failures.
+- `opencertserver.crl.generation.duration`: Histogram for generation duration.
+
+### Traces
+- `opencertserver.crl.request`: Span for CRL request.
+- `opencertserver.crl.generation`: Span for CRL generation, sub-spans for certificate list building, signing.
+
+**Collection Location**: In CRL handler (likely in `src/opencertserver.ca.server/`), wrap request and generation methods.
+
+## CA Endpoints
+
+### Metrics
+- `opencertserver.ca.csr.requests`: Counter for /ca/csr POST requests.
+- `opencertserver.ca.csr.successes`: Counter for successful CSR signings.
+- `opencertserver.ca.csr.failures`: Counter for CSR signing failures.
+- `opencertserver.ca.csr.duration`: Histogram for CSR signing duration.
+- `opencertserver.ca.revoke.requests`: Counter for /ca/revoke DELETE requests.
+- `opencertserver.ca.revoke.successes`: Counter for successful revocations.
+- `opencertserver.ca.revoke.failures`: Counter for revocation failures.
+- `opencertserver.ca.revoke.duration`: Histogram for revocation duration.
+- `opencertserver.ca.inventory.requests`: Counter for /ca/inventory GET requests.
+- `opencertserver.ca.inventory.successes`: Counter for successful inventory responses.
+- `opencertserver.ca.inventory.failures`: Counter for inventory failures.
+- `opencertserver.ca.inventory.duration`: Histogram for inventory duration.
+
+### Traces
+- `opencertserver.ca.csr`: Span for CSR signing, sub-spans for validation, signing.
+- `opencertserver.ca.revoke`: Span for revocation, sub-spans for proof verification.
+- `opencertserver.ca.inventory`: Span for inventory retrieval.
+
+**Collection Location**: In `src/opencertserver.ca.server/`, relevant handlers.
+
+## CLI Operations
+
+### Metrics
+- `opencertserver.cli.generatekeys.requests`: Counter for generate-keys commands.
+- `opencertserver.cli.generatekeys.successes`: Counter for successful key generations.
+- `opencertserver.cli.generatekeys.failures`: Counter for failures.
+- `opencertserver.cli.generatekeys.duration`: Histogram for duration.
+- `opencertserver.cli.printcert.requests`: Counter for print-cert commands.
+- `opencertserver.cli.printcert.successes`: Counter for successful prints.
+- `opencertserver.cli.printcert.failures`: Counter for failures.
+- `opencertserver.cli.printcert.duration`: Histogram for duration.
+- `opencertserver.cli.createcsr.requests`: Counter for create-csr commands.
+- `opencertserver.cli.createcsr.successes`: Counter for successful CSRs.
+- `opencertserver.cli.createcsr.failures`: Counter for failures.
+- `opencertserver.cli.createcsr.duration`: Histogram for duration.
+- `opencertserver.cli.signcsr.requests`: Counter for sign-csr commands.
+- `opencertserver.cli.signcsr.successes`: Counter for successful signings.
+- `opencertserver.cli.signcsr.failures`: Counter for failures.
+- `opencertserver.cli.signcsr.duration`: Histogram for duration.
+- `opencertserver.cli.estenroll.requests`: Counter for est-enroll commands.
+- `opencertserver.cli.estenroll.successes`: Counter for successful enrollments.
+- `opencertserver.cli.estenroll.failures`: Counter for failures.
+- `opencertserver.cli.estenroll.duration`: Histogram for duration.
+- `opencertserver.cli.estreerenroll.requests`: Counter for est-reenroll commands.
+- `opencertserver.cli.estreerenroll.successes`: Counter for successful re-enrollments.
+- `opencertserver.cli.estreerenroll.failures`: Counter for failures.
+- `opencertserver.cli.estreerenroll.duration`: Histogram for duration.
+- `opencertserver.cli.estservercertificates.requests`: Counter for est-server-certificates commands.
+- `opencertserver.cli.estservercertificates.successes`: Counter for successful fetches.
+- `opencertserver.cli.estservercertificates.failures`: Counter for failures.
+- `opencertserver.cli.estservercertificates.duration`: Histogram for duration.
+
+### Traces
+- `opencertserver.cli.generatekeys`: Span for key generation.
+- `opencertserver.cli.printcert`: Span for certificate printing.
+- `opencertserver.cli.createcsr`: Span for CSR creation.
+- `opencertserver.cli.signcsr`: Span for CSR signing.
+- `opencertserver.cli.estenroll`: Span for EST enrollment, sub-spans for client operations.
+- `opencertserver.cli.estreerenroll`: Span for EST re-enrollment.
+- `opencertserver.cli.estservercertificates`: Span for CA cert fetch.
+
+**Collection Location**: In `src/opencertserver.cli/`, wrap command handlers.
+
+## Additional Cross-Cutting Metrics
+- `opencertserver.auth.failures`: Counter for authentication failures across protocols.
+- `opencertserver.tls.errors`: Counter for TLS-related errors.
+- `opencertserver.errors.internal`: Counter for internal server errors.
+
+**Collection Location**: In middleware or base handlers.
diff --git a/src/opencertserver.acme.server/AcmeInstruments.cs b/src/opencertserver.acme.server/AcmeInstruments.cs
new file mode 100644
index 0000000..a18c17b
--- /dev/null
+++ b/src/opencertserver.acme.server/AcmeInstruments.cs
@@ -0,0 +1,67 @@
+namespace OpenCertServer.Acme.Server;
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+/// OpenTelemetry instruments for all ACME request handlers (RFC 8555).
+internal static class AcmeInstruments
+{
+ private static readonly Meter Meter = new(MetricNames.MeterName, "1.0.0");
+
+ internal static readonly ActivitySource ActivitySource = new(MetricNames.MeterName, "1.0.0");
+
+ // /directory
+ internal static readonly Counter DirectoryRequests = Meter.CreateCounter (MetricNames.DirectoryRequests, description: "Total /directory requests");
+ internal static readonly Counter DirectorySuccesses = Meter.CreateCounter (MetricNames.DirectorySuccesses, description: "Successful /directory responses");
+ internal static readonly Counter DirectoryFailures = Meter.CreateCounter (MetricNames.DirectoryFailures, description: "Failed /directory responses");
+ internal static readonly Histogram DirectoryDuration = Meter.CreateHistogram (MetricNames.DirectoryDuration, "s", "Duration of /directory requests");
+
+ // /new-nonce
+ internal static readonly Counter NewNonceRequests = Meter.CreateCounter (MetricNames.NewNonceRequests);
+ internal static readonly Counter NewNonceSuccesses = Meter.CreateCounter (MetricNames.NewNonceSuccesses);
+ internal static readonly Counter NewNonceFailures = Meter.CreateCounter (MetricNames.NewNonceFailures);
+ internal static readonly Histogram NewNonceDuration = Meter.CreateHistogram (MetricNames.NewNonceDuration, "s");
+
+ // /new-account
+ internal static readonly Counter NewAccountRequests = Meter.CreateCounter (MetricNames.NewAccountRequests, description: "Total /new-account requests");
+ internal static readonly Counter NewAccountSuccesses = Meter.CreateCounter (MetricNames.NewAccountSuccesses, description: "Successful /new-account responses");
+ internal static readonly Counter NewAccountFailures = Meter.CreateCounter (MetricNames.NewAccountFailures, description: "Failed /new-account responses");
+ internal static readonly Histogram NewAccountDuration = Meter.CreateHistogram (MetricNames.NewAccountDuration, "s", "Duration of /new-account requests");
+
+ // /new-order
+ internal static readonly Counter NewOrderRequests = Meter.CreateCounter (MetricNames.NewOrderRequests, description: "Total /new-order requests");
+ internal static readonly Counter NewOrderSuccesses = Meter.CreateCounter (MetricNames.NewOrderSuccesses, description: "Successful /new-order responses");
+ internal static readonly Counter NewOrderFailures = Meter.CreateCounter (MetricNames.NewOrderFailures, description: "Failed /new-order responses");
+ internal static readonly Histogram NewOrderDuration = Meter.CreateHistogram (MetricNames.NewOrderDuration, "s", "Duration of /new-order requests");
+
+ // /order/{id}/finalize
+ internal static readonly Counter OrderFinalizeRequests = Meter.CreateCounter (MetricNames.OrderFinalizeRequests);
+ internal static readonly Counter OrderFinalizeSuccesses = Meter.CreateCounter (MetricNames.OrderFinalizeSuccesses);
+ internal static readonly Counter OrderFinalizeFailures = Meter.CreateCounter (MetricNames.OrderFinalizeFailures);
+ internal static readonly Histogram OrderFinalizeDuration = Meter.CreateHistogram (MetricNames.OrderFinalizeDuration, "s");
+
+ // /order/{id}/certificate
+ internal static readonly Counter CertificateRequests = Meter.CreateCounter (MetricNames.CertificateRequests);
+ internal static readonly Counter CertificateSuccesses = Meter.CreateCounter (MetricNames.CertificateSuccesses);
+ internal static readonly Counter CertificateFailures = Meter.CreateCounter (MetricNames.CertificateFailures);
+ internal static readonly Histogram CertificateDuration = Meter.CreateHistogram (MetricNames.CertificateDuration, "s");
+
+ // Challenge validation (ValidationWorker)
+ internal static readonly Counter ChallengeValidationRequests = Meter.CreateCounter (MetricNames.ChallengeValidationRequests, description: "Total ACME challenge validation attempts");
+ internal static readonly Counter ChallengeValidationSuccesses = Meter.CreateCounter (MetricNames.ChallengeValidationSuccesses, description: "Successful ACME challenge validations");
+ internal static readonly Counter ChallengeValidationFailures = Meter.CreateCounter (MetricNames.ChallengeValidationFailures, description: "Failed ACME challenge validations");
+ internal static readonly Histogram ChallengeValidationDuration = Meter.CreateHistogram (MetricNames.ChallengeValidationDuration, "s", "Duration of challenge validation");
+ internal static readonly UpDownCounter ChallengeValidationActive = Meter.CreateUpDownCounter (MetricNames.ChallengeValidationActive, description: "Active pending ACME challenge validations");
+
+ // /key-change
+ internal static readonly Counter KeyChangeRequests = Meter.CreateCounter (MetricNames.KeyChangeRequests);
+ internal static readonly Counter KeyChangeSuccesses = Meter.CreateCounter (MetricNames.KeyChangeSuccesses);
+ internal static readonly Counter KeyChangeFailures = Meter.CreateCounter (MetricNames.KeyChangeFailures);
+ internal static readonly Histogram KeyChangeDuration = Meter.CreateHistogram (MetricNames.KeyChangeDuration, "s");
+
+ // /revoke-cert
+ internal static readonly Counter RevokeRequests = Meter.CreateCounter (MetricNames.RevokeRequests);
+ internal static readonly Counter RevokeSuccesses = Meter.CreateCounter (MetricNames.RevokeSuccesses);
+ internal static readonly Counter RevokeFailures = Meter.CreateCounter (MetricNames.RevokeFailures);
+ internal static readonly Histogram RevokeDuration = Meter.CreateHistogram (MetricNames.RevokeDuration, "s");
+}
diff --git a/src/opencertserver.acme.server/ActivityNames.cs b/src/opencertserver.acme.server/ActivityNames.cs
new file mode 100644
index 0000000..03515cb
--- /dev/null
+++ b/src/opencertserver.acme.server/ActivityNames.cs
@@ -0,0 +1,19 @@
+namespace OpenCertServer.Acme.Server;
+
+///
+/// Centralised activity (span) name constants for all ACME OpenTelemetry traces.
+/// All spans MUST reference these constants; never use inline string literals for activity names.
+///
+internal static class ActivityNames
+{
+ internal const string Directory = "opencertserver.acme.directory";
+ internal const string NewNonce = "opencertserver.acme.newnonce";
+ internal const string NewAccount = "opencertserver.acme.newaccount";
+ internal const string NewOrder = "opencertserver.acme.neworder";
+ internal const string OrderFinalize = "opencertserver.acme.orderfinalize";
+ internal const string Certificate = "opencertserver.acme.certificate";
+ internal const string ChallengeValidation = "opencertserver.acme.challengevalidation";
+ internal const string KeyChange = "opencertserver.acme.keychange";
+ internal const string Revoke = "opencertserver.acme.revoke";
+}
+
diff --git a/src/opencertserver.acme.server/Endpoints/AccountEndpoints.cs b/src/opencertserver.acme.server/Endpoints/AccountEndpoints.cs
index 710de60..15d3c0e 100644
--- a/src/opencertserver.acme.server/Endpoints/AccountEndpoints.cs
+++ b/src/opencertserver.acme.server/Endpoints/AccountEndpoints.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Acme.Server.Endpoints;
+using System.Diagnostics;
using System.Text;
using System.Text.Json;
using CertesSlim.Acme.Resource;
@@ -115,6 +116,11 @@ private static async Task KeyChangeHandler(
LinkGenerator links,
CancellationToken cancellationToken)
{
+ AcmeInstruments.KeyChangeRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.KeyChange);
+ try
+ {
// RFC 8555 §7.3.5: The outer JWS is signed by the CURRENT (old) account key and
// must identify the account with a "kid" header parameter.
// The request validation middleware has already verified the outer signature via kid.
@@ -183,7 +189,19 @@ private static async Task KeyChangeHandler(
account = await accountService.ChangeKey(account, newKey, cancellationToken).ConfigureAwait(false);
context.Response.Headers.Location = accountUrl;
- return Results.Ok(CreateAccountResponse(context, links, account));
+ var keyChangeResult = Results.Ok(CreateAccountResponse(context, links, account));
+ AcmeInstruments.KeyChangeSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ AcmeInstruments.KeyChangeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return keyChangeResult;
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.KeyChangeFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ AcmeInstruments.KeyChangeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
+ }
}
private static async Task NewAccountHandler(
@@ -195,6 +213,11 @@ private static async Task NewAccountHandler(
[FromServices] LinkGenerator links,
CancellationToken cancellationToken)
{
+ AcmeInstruments.NewAccountRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.NewAccount);
+ try
+ {
var options = optionsAccessor.Value;
var header = JsonSerializer.Deserialize(Base64UrlEncoder.Decode(jwsPayload.Protected)!,
AcmeSerializerContext.Default.AcmeHeader)!;
@@ -256,7 +279,18 @@ private static async Task NewAccountHandler(
payload.TermsOfServiceAgreed == true, externalAccountId, cancellationToken).ConfigureAwait(false);
var createdAccountResponse = CreateAccountResponse(context, links, createdAccount);
var createdAccountUrl = GetAccountUrl(context, links, createdAccount.AccountId);
+ AcmeInstruments.NewAccountSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ AcmeInstruments.NewAccountDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
return Results.Created(createdAccountUrl, createdAccountResponse);
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.NewAccountFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ AcmeInstruments.NewAccountDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
+ }
}
private static Account CreateAccountResponse(
diff --git a/src/opencertserver.acme.server/Endpoints/DirectoryEndpoints.cs b/src/opencertserver.acme.server/Endpoints/DirectoryEndpoints.cs
index c862c97..4f0d008 100644
--- a/src/opencertserver.acme.server/Endpoints/DirectoryEndpoints.cs
+++ b/src/opencertserver.acme.server/Endpoints/DirectoryEndpoints.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Acme.Server.Endpoints;
+using System.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -16,20 +17,43 @@ public static IEndpointRouteBuilder MapDirectoryEndpoints(this IEndpointRouteBui
private static IResult GetDirectoryHandler(HttpContext context, IOptions optionsAccessor, LinkGenerator links)
{
- var options = optionsAccessor.Value;
-
- var directory = new OpenCertServer.Acme.Abstractions.HttpModel.Directory
+ AcmeInstruments.DirectoryRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.Directory);
+ try
{
- NewNonce = GetUrl("NewNonce"),
- NewAccount = GetUrl("NewAccount"),
- NewOrder = GetUrl("NewOrder"),
- NewAuthz = null,
- RevokeCert = GetUrl("RevokeCert"),
- KeyChange = GetUrl("KeyChange"),
- Meta = new OpenCertServer.Acme.Abstractions.HttpModel.DirectoryMetadata { ExternalAccountRequired = options.ExternalAccountRequired, CAAIdentities = null, TermsOfService = options.TOS.RequireAgreement ? options.TOS.Url : null, Website = options.WebsiteUrl }
- };
- return Results.Ok(directory);
+ var options = optionsAccessor.Value;
+ var directory = new OpenCertServer.Acme.Abstractions.HttpModel.Directory
+ {
+ NewNonce = GetUrl("NewNonce"),
+ NewAccount = GetUrl("NewAccount"),
+ NewOrder = GetUrl("NewOrder"),
+ NewAuthz = null,
+ RevokeCert = GetUrl("RevokeCert"),
+ KeyChange = GetUrl("KeyChange"),
+ Meta = new OpenCertServer.Acme.Abstractions.HttpModel.DirectoryMetadata
+ {
+ ExternalAccountRequired = options.ExternalAccountRequired,
+ CAAIdentities = null,
+ TermsOfService = options.TOS.RequireAgreement ? options.TOS.Url : null,
+ Website = options.WebsiteUrl
+ }
+ };
+ AcmeInstruments.DirectorySuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ return Results.Ok(directory);
- string? GetUrl(string routeName) => links.GetUriByName(context, routeName, values: null, scheme: Uri.UriSchemeHttps);
+ string? GetUrl(string routeName) => links.GetUriByName(context, routeName, values: null, scheme: Uri.UriSchemeHttps);
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.DirectoryFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ AcmeInstruments.DirectoryDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ }
}
}
diff --git a/src/opencertserver.acme.server/Endpoints/NonceEndpoints.cs b/src/opencertserver.acme.server/Endpoints/NonceEndpoints.cs
index 312d75b..fe4ae43 100644
--- a/src/opencertserver.acme.server/Endpoints/NonceEndpoints.cs
+++ b/src/opencertserver.acme.server/Endpoints/NonceEndpoints.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Acme.Server.Endpoints;
+using System.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -13,18 +14,53 @@ public static IEndpointRouteBuilder MapNonceEndpoints(this IEndpointRouteBuilder
// HEAD /new-nonce
endpoints.MapMethods("/new-nonce", ["HEAD"], async (HttpContext context, INonceService nonceService, ILogger logger) =>
{
- await EnsureReplayNonceHeaderAsync(context, nonceService, logger).ConfigureAwait(false);
- context.Response.StatusCode = StatusCodes.Status200OK;
+ AcmeInstruments.NewNonceRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.NewNonce);
+ try
+ {
+ await EnsureReplayNonceHeaderAsync(context, nonceService, logger).ConfigureAwait(false);
+ context.Response.StatusCode = StatusCodes.Status200OK;
+ AcmeInstruments.NewNonceSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.NewNonceFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ AcmeInstruments.NewNonceDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ }
})
.WithName("NewNonce");
// GET /new-nonce
endpoints.MapGet("/new-nonce", async (HttpContext context, INonceService nonceService, ILogger logger) =>
{
- await EnsureReplayNonceHeaderAsync(context, nonceService, logger).ConfigureAwait(false);
- context.Response.StatusCode = StatusCodes.Status204NoContent;
+ AcmeInstruments.NewNonceRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.NewNonce);
+ try
+ {
+ await EnsureReplayNonceHeaderAsync(context, nonceService, logger).ConfigureAwait(false);
+ context.Response.StatusCode = StatusCodes.Status204NoContent;
+ AcmeInstruments.NewNonceSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.NewNonceFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ AcmeInstruments.NewNonceDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ }
});
-// .WithName("NewNonce");
return endpoints;
}
diff --git a/src/opencertserver.acme.server/Endpoints/OrderEndpoints.cs b/src/opencertserver.acme.server/Endpoints/OrderEndpoints.cs
index 484d26f..2b13d02 100644
--- a/src/opencertserver.acme.server/Endpoints/OrderEndpoints.cs
+++ b/src/opencertserver.acme.server/Endpoints/OrderEndpoints.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Acme.Server.Endpoints;
+using System.Diagnostics;
using CertesSlim.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -26,6 +27,11 @@ public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder
IOptions optionsAccessor,
CancellationToken cancellationToken) =>
{
+ AcmeInstruments.NewOrderRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.NewOrder);
+ try
+ {
var header = payload.ToAcmeHeader();
var account = await accountService.FromRequest(header, cancellationToken).ConfigureAwait(false);
@@ -71,7 +77,18 @@ public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder
new RouteValueDictionary([KeyValuePair.Create("orderId", order.OrderId)]),
scheme: Uri.UriSchemeHttps) ??
string.Empty;
+ AcmeInstruments.NewOrderSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ AcmeInstruments.NewOrderDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
return Results.Created(orderUrl, orderResponse);
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.NewOrderFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ AcmeInstruments.NewOrderDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
+ }
}).WithName("NewOrder");
endpoints.MapPost("/order/{orderId}", async (
@@ -194,19 +211,35 @@ public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder
LinkGenerator links,
CancellationToken cancellationToken) =>
{
- var account = await accountService.FromRequest(payload.ToAcmeHeader(), cancellationToken).ConfigureAwait(false);
- var orderRequest = payload.ToPayload();
- if (string.IsNullOrWhiteSpace(orderRequest?.Csr))
+ AcmeInstruments.OrderFinalizeRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.OrderFinalize);
+ try
{
- throw new MalformedRequestException("CSR was empty or could not be read.");
- }
+ var account = await accountService.FromRequest(payload.ToAcmeHeader(), cancellationToken).ConfigureAwait(false);
+ var orderRequest = payload.ToPayload();
+ if (string.IsNullOrWhiteSpace(orderRequest?.Csr))
+ {
+ throw new MalformedRequestException("CSR was empty or could not be read.");
+ }
- var order = await orderService.ProcessCsr(account, orderId, orderRequest.Csr, cancellationToken).ConfigureAwait(false);
- GetOrderUrls(context, links, order, out var authorizationUrls, out var finalizeUrl, out var certificateUrl);
- var orderResponse =
- new OpenCertServer.Acme.Abstractions.HttpModel.Order(order, authorizationUrls, finalizeUrl,
- certificateUrl);
- return Results.Ok(orderResponse);
+ var order = await orderService.ProcessCsr(account, orderId, orderRequest.Csr, cancellationToken).ConfigureAwait(false);
+ GetOrderUrls(context, links, order, out var authorizationUrls, out var finalizeUrl, out var certificateUrl);
+ var orderResponse =
+ new OpenCertServer.Acme.Abstractions.HttpModel.Order(order, authorizationUrls, finalizeUrl,
+ certificateUrl);
+ AcmeInstruments.OrderFinalizeSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ AcmeInstruments.OrderFinalizeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return Results.Ok(orderResponse);
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.OrderFinalizeFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ AcmeInstruments.OrderFinalizeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
+ }
}).WithName("FinalizeOrder").AddEndpointFilter();
endpoints.MapPost("/order/{orderId}/certificate", [AcmeLocation("GetOrder")] async (
@@ -216,9 +249,25 @@ public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder
IAccountService accountService,
CancellationToken cancellationToken) =>
{
- var account = await accountService.FromRequest(payload.ToAcmeHeader(), cancellationToken).ConfigureAwait(false);
- var certificateChainBytes = await orderService.GetCertificate(account, orderId, cancellationToken).ConfigureAwait(false);
- return Results.File(certificateChainBytes, "application/pem-certificate-chain");
+ AcmeInstruments.CertificateRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.Certificate);
+ try
+ {
+ var account = await accountService.FromRequest(payload.ToAcmeHeader(), cancellationToken).ConfigureAwait(false);
+ var certificateChainBytes = await orderService.GetCertificate(account, orderId, cancellationToken).ConfigureAwait(false);
+ AcmeInstruments.CertificateSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ AcmeInstruments.CertificateDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return Results.File(certificateChainBytes, "application/pem-certificate-chain");
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.CertificateFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ AcmeInstruments.CertificateDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
+ }
}).WithName("GetCertificate").AddEndpointFilter();
return endpoints;
diff --git a/src/opencertserver.acme.server/Endpoints/RevocationEndpoints.cs b/src/opencertserver.acme.server/Endpoints/RevocationEndpoints.cs
index c4d9501..5be4da7 100644
--- a/src/opencertserver.acme.server/Endpoints/RevocationEndpoints.cs
+++ b/src/opencertserver.acme.server/Endpoints/RevocationEndpoints.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Acme.Server.Endpoints;
+using System.Diagnostics;
using CertesSlim.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -18,18 +19,32 @@ public static IEndpointRouteBuilder MapRevocationEndpoints(this IEndpointRouteBu
IRevocationService revocationService,
CancellationToken cancellationToken) =>
{
- var request = payload.ToPayload();
- if (request == null)
+ AcmeInstruments.RevokeRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.Revoke);
+ try
{
- throw new MalformedRequestException("The revocation request payload was empty or could not be read.");
- }
+ var request = payload.ToPayload();
+ if (request == null)
+ {
+ throw new MalformedRequestException("The revocation request payload was empty or could not be read.");
+ }
- await revocationService.RevokeCertificate(payload.ToAcmeHeader(), request, cancellationToken).ConfigureAwait(false);
- return Results.Ok();
+ await revocationService.RevokeCertificate(payload.ToAcmeHeader(), request, cancellationToken).ConfigureAwait(false);
+ AcmeInstruments.RevokeSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ AcmeInstruments.RevokeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return Results.Ok();
+ }
+ catch (Exception ex)
+ {
+ AcmeInstruments.RevokeFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ AcmeInstruments.RevokeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
+ }
}).WithName("RevokeCert");
return endpoints;
}
}
-
-
diff --git a/src/opencertserver.acme.server/MetricNames.cs b/src/opencertserver.acme.server/MetricNames.cs
new file mode 100644
index 0000000..9c66893
--- /dev/null
+++ b/src/opencertserver.acme.server/MetricNames.cs
@@ -0,0 +1,66 @@
+namespace OpenCertServer.Acme.Server;
+
+///
+/// Centralised metric name constants for all ACME OpenTelemetry instruments.
+/// All metric instrument creation MUST reference these constants.
+///
+internal static class MetricNames
+{
+ internal const string MeterName = "opencertserver.acme";
+
+ // /directory
+ internal const string DirectoryRequests = "opencertserver.acme.directory.requests";
+ internal const string DirectorySuccesses = "opencertserver.acme.directory.successes";
+ internal const string DirectoryFailures = "opencertserver.acme.directory.failures";
+ internal const string DirectoryDuration = "opencertserver.acme.directory.duration";
+
+ // /new-nonce
+ internal const string NewNonceRequests = "opencertserver.acme.newnonce.requests";
+ internal const string NewNonceSuccesses = "opencertserver.acme.newnonce.successes";
+ internal const string NewNonceFailures = "opencertserver.acme.newnonce.failures";
+ internal const string NewNonceDuration = "opencertserver.acme.newnonce.duration";
+
+ // /new-account
+ internal const string NewAccountRequests = "opencertserver.acme.newaccount.requests";
+ internal const string NewAccountSuccesses = "opencertserver.acme.newaccount.successes";
+ internal const string NewAccountFailures = "opencertserver.acme.newaccount.failures";
+ internal const string NewAccountDuration = "opencertserver.acme.newaccount.duration";
+
+ // /new-order
+ internal const string NewOrderRequests = "opencertserver.acme.neworder.requests";
+ internal const string NewOrderSuccesses = "opencertserver.acme.neworder.successes";
+ internal const string NewOrderFailures = "opencertserver.acme.neworder.failures";
+ internal const string NewOrderDuration = "opencertserver.acme.neworder.duration";
+
+ // /order/{id}/finalize
+ internal const string OrderFinalizeRequests = "opencertserver.acme.orderfinalize.requests";
+ internal const string OrderFinalizeSuccesses = "opencertserver.acme.orderfinalize.successes";
+ internal const string OrderFinalizeFailures = "opencertserver.acme.orderfinalize.failures";
+ internal const string OrderFinalizeDuration = "opencertserver.acme.orderfinalize.duration";
+
+ // /order/{id}/certificate
+ internal const string CertificateRequests = "opencertserver.acme.certificate.requests";
+ internal const string CertificateSuccesses = "opencertserver.acme.certificate.successes";
+ internal const string CertificateFailures = "opencertserver.acme.certificate.failures";
+ internal const string CertificateDuration = "opencertserver.acme.certificate.duration";
+
+ // Challenge validation (ValidationWorker)
+ internal const string ChallengeValidationRequests = "opencertserver.acme.challengevalidation.requests";
+ internal const string ChallengeValidationSuccesses = "opencertserver.acme.challengevalidation.successes";
+ internal const string ChallengeValidationFailures = "opencertserver.acme.challengevalidation.failures";
+ internal const string ChallengeValidationDuration = "opencertserver.acme.challengevalidation.duration";
+ internal const string ChallengeValidationActive = "opencertserver.acme.challengevalidation.active";
+
+ // /key-change
+ internal const string KeyChangeRequests = "opencertserver.acme.keychange.requests";
+ internal const string KeyChangeSuccesses = "opencertserver.acme.keychange.successes";
+ internal const string KeyChangeFailures = "opencertserver.acme.keychange.failures";
+ internal const string KeyChangeDuration = "opencertserver.acme.keychange.duration";
+
+ // /revoke-cert
+ internal const string RevokeRequests = "opencertserver.acme.revoke.requests";
+ internal const string RevokeSuccesses = "opencertserver.acme.revoke.successes";
+ internal const string RevokeFailures = "opencertserver.acme.revoke.failures";
+ internal const string RevokeDuration = "opencertserver.acme.revoke.duration";
+}
+
diff --git a/src/opencertserver.acme.server/TagKeys.cs b/src/opencertserver.acme.server/TagKeys.cs
new file mode 100644
index 0000000..c6bc89e
--- /dev/null
+++ b/src/opencertserver.acme.server/TagKeys.cs
@@ -0,0 +1,24 @@
+namespace OpenCertServer.Acme.Server;
+
+///
+/// Centralised tag (attribute) key constants for all ACME OpenTelemetry measurements.
+/// Use these keys when adding dimensions to counters, histograms and spans.
+///
+internal static class TagKeys
+{
+ /// ACME challenge type, e.g. "http-01", "dns-01", "tls-alpn-01".
+ internal const string ChallengeType = "acme.challenge.type";
+
+ /// ACME order identifier type, e.g. "dns".
+ internal const string IdentifierType = "acme.identifier.type";
+
+ ///
+ /// Structured error type; set to the exception class name or ACME error type string
+ /// on all failure counter and span recordings.
+ ///
+ internal const string ErrorType = "error.type";
+
+ /// HTTP response status code recorded on non-2xx result paths.
+ internal const string HttpStatusCode = "http.response.status_code";
+}
+
diff --git a/src/opencertserver.acme.server/Workers/ValidationWorker.cs b/src/opencertserver.acme.server/Workers/ValidationWorker.cs
index 3c4a4f3..51ddd35 100644
--- a/src/opencertserver.acme.server/Workers/ValidationWorker.cs
+++ b/src/opencertserver.acme.server/Workers/ValidationWorker.cs
@@ -3,6 +3,7 @@
namespace OpenCertServer.Acme.Server.Workers;
using System;
+using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Abstractions.Model;
@@ -66,27 +67,49 @@ private async Task ValidateOrder(Order order, CancellationToken cancellationToke
var challenge = pendingAuthZ.Challenges[0];
- var validator = _challengeValidatorFactory.GetValidator(challenge);
- var (isValid, error) = await validator.ValidateChallenge(challenge, account, cancellationToken).ConfigureAwait(false);
-
- if (isValid)
+ AcmeInstruments.ChallengeValidationRequests.Add(1);
+ AcmeInstruments.ChallengeValidationActive.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.ChallengeValidation);
+ try
{
- challenge.Error = null;
- challenge.Validated = DateTimeOffset.UtcNow;
- challenge.SetStatus(ChallengeStatus.Valid);
- pendingAuthZ.SetStatus(AuthorizationStatus.Valid);
+ var validator = _challengeValidatorFactory.GetValidator(challenge);
+ var (isValid, error) = await validator.ValidateChallenge(challenge, account, cancellationToken).ConfigureAwait(false);
- if (order.Error != null && string.Equals(order.Error.Type, "urn:ietf:params:acme:error:unauthorized", StringComparison.Ordinal))
+ if (isValid)
+ {
+ challenge.Error = null;
+ challenge.Validated = DateTimeOffset.UtcNow;
+ challenge.SetStatus(ChallengeStatus.Valid);
+ pendingAuthZ.SetStatus(AuthorizationStatus.Valid);
+ if (order.Error != null && string.Equals(order.Error.Type, "urn:ietf:params:acme:error:unauthorized", StringComparison.Ordinal))
+ {
+ order.Error = null;
+ }
+
+ AcmeInstruments.ChallengeValidationSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+ else
{
- order.Error = null;
+ challenge.Error = error ?? new AcmeError("serverInternal", "Challenge validation failed.", pendingAuthZ.Identifier);
+ challenge.SetStatus(ChallengeStatus.Invalid);
+ pendingAuthZ.SetStatus(AuthorizationStatus.Invalid);
+ order.Error = challenge.Error;
+ AcmeInstruments.ChallengeValidationFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error);
}
}
- else
+ catch (Exception ex)
+ {
+ AcmeInstruments.ChallengeValidationFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
{
- challenge.Error = error ?? new AcmeError("serverInternal", "Challenge validation failed.", pendingAuthZ.Identifier);
- challenge.SetStatus(ChallengeStatus.Invalid);
- pendingAuthZ.SetStatus(AuthorizationStatus.Invalid);
- order.Error = challenge.Error;
+ AcmeInstruments.ChallengeValidationActive.Add(-1);
+ AcmeInstruments.ChallengeValidationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
}
}
diff --git a/src/opencertserver.ca.server/ActivityNames.cs b/src/opencertserver.ca.server/ActivityNames.cs
new file mode 100644
index 0000000..c6d383c
--- /dev/null
+++ b/src/opencertserver.ca.server/ActivityNames.cs
@@ -0,0 +1,17 @@
+namespace OpenCertServer.Ca.Server;
+
+///
+/// Centralised activity (span) name constants for all CA/OCSP/CRL OpenTelemetry traces.
+/// All spans MUST reference these constants; never use inline string literals for activity names.
+///
+internal static class ActivityNames
+{
+ internal const string OcspRequest = "opencertserver.ocsp.request";
+ internal const string CrlRequest = "opencertserver.crl.request";
+ internal const string CrlGeneration = "opencertserver.crl.generation";
+ internal const string CsrSign = "opencertserver.ca.csr";
+ internal const string Revoke = "opencertserver.ca.revoke";
+ internal const string Inventory = "opencertserver.ca.inventory";
+ internal const string CertificateRetrieval = "opencertserver.ca.certretrieval";
+}
+
diff --git a/src/opencertserver.ca.server/CaInstruments.cs b/src/opencertserver.ca.server/CaInstruments.cs
new file mode 100644
index 0000000..4636710
--- /dev/null
+++ b/src/opencertserver.ca.server/CaInstruments.cs
@@ -0,0 +1,55 @@
+namespace OpenCertServer.Ca.Server;
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+/// OpenTelemetry instruments for CA, OCSP, and CRL request handlers (RFC 6960, RFC 5280).
+internal static class CaInstruments
+{
+ private static readonly Meter Meter = new(MetricNames.MeterName, "1.0.0");
+
+ internal static readonly ActivitySource ActivitySource = new(MetricNames.MeterName, "1.0.0");
+
+ // OCSP – RFC 6960
+ internal static readonly Counter OcspRequests = Meter.CreateCounter (MetricNames.OcspRequests, description: "Total OCSP requests");
+ internal static readonly Counter OcspSuccesses = Meter.CreateCounter (MetricNames.OcspSuccesses, description: "Successful OCSP responses");
+ internal static readonly Counter OcspFailures = Meter.CreateCounter (MetricNames.OcspFailures, description: "Failed OCSP responses");
+ internal static readonly Histogram OcspDuration = Meter.CreateHistogram (MetricNames.OcspDuration, "s", "Duration of OCSP request processing");
+
+ // CRL requests – RFC 5280
+ internal static readonly Counter CrlRequests = Meter.CreateCounter (MetricNames.CrlRequests, description: "Total CRL requests");
+ internal static readonly Counter CrlSuccesses = Meter.CreateCounter (MetricNames.CrlSuccesses, description: "Successful CRL responses");
+ internal static readonly Counter CrlFailures = Meter.CreateCounter (MetricNames.CrlFailures, description: "Failed CRL responses");
+ internal static readonly Histogram CrlDuration = Meter.CreateHistogram (MetricNames.CrlDuration, "s", "Duration of CRL requests");
+
+ // CRL generation
+ internal static readonly Counter CrlGenerationRequests = Meter.CreateCounter (MetricNames.CrlGenerationRequests, description: "Total CRL generation requests");
+ internal static readonly Counter CrlGenerationSuccesses = Meter.CreateCounter (MetricNames.CrlGenerationSuccesses, description: "Successful CRL generations");
+ internal static readonly Counter CrlGenerationFailures = Meter.CreateCounter (MetricNames.CrlGenerationFailures, description: "Failed CRL generations");
+ internal static readonly Histogram CrlGenerationDuration = Meter.CreateHistogram (MetricNames.CrlGenerationDuration, "s", "Duration of CRL generation");
+
+ // /ca/csr
+ internal static readonly Counter CsrRequests = Meter.CreateCounter (MetricNames.CsrRequests, description: "Total CSR signing requests");
+ internal static readonly Counter CsrSuccesses = Meter.CreateCounter (MetricNames.CsrSuccesses, description: "Successful CSR signings");
+ internal static readonly Counter CsrFailures = Meter.CreateCounter (MetricNames.CsrFailures, description: "Failed CSR signings");
+ internal static readonly Histogram CsrDuration = Meter.CreateHistogram (MetricNames.CsrDuration, "s", "Duration of CSR signing");
+
+ // /ca/revoke
+ internal static readonly Counter RevocationRequests = Meter.CreateCounter (MetricNames.RevocationRequests, description: "Total revocation requests");
+ internal static readonly Counter RevocationSuccesses = Meter.CreateCounter (MetricNames.RevocationSuccesses, description: "Successful revocations");
+ internal static readonly Counter RevocationFailures = Meter.CreateCounter (MetricNames.RevocationFailures, description: "Failed revocations");
+ internal static readonly Histogram RevocationDuration = Meter.CreateHistogram (MetricNames.RevocationDuration, "s", "Duration of revocation requests");
+
+ // /ca/inventory
+ internal static readonly Counter InventoryRequests = Meter.CreateCounter (MetricNames.InventoryRequests, description: "Total inventory requests");
+ internal static readonly Counter InventorySuccesses = Meter.CreateCounter (MetricNames.InventorySuccesses, description: "Successful inventory responses");
+ internal static readonly Counter InventoryFailures = Meter.CreateCounter (MetricNames.InventoryFailures, description: "Failed inventory responses");
+ internal static readonly Histogram InventoryDuration = Meter.CreateHistogram (MetricNames.InventoryDuration, "s", "Duration of inventory requests");
+
+ // /ca/certificates (retrieval by thumbprint / id)
+ internal static readonly Counter CertRetrievalRequests = Meter.CreateCounter (MetricNames.CertRetrievalRequests, description: "Total certificate retrieval requests");
+ internal static readonly Counter CertRetrievalSuccesses = Meter.CreateCounter (MetricNames.CertRetrievalSuccesses, description: "Successful certificate retrievals");
+ internal static readonly Counter CertRetrievalFailures = Meter.CreateCounter (MetricNames.CertRetrievalFailures, description: "Failed certificate retrievals");
+ internal static readonly Histogram CertRetrievalDuration = Meter.CreateHistogram (MetricNames.CertRetrievalDuration, "s", "Duration of certificate retrieval requests");
+}
+
diff --git a/src/opencertserver.ca.server/Handlers/CertificateRetrievalHandler.cs b/src/opencertserver.ca.server/Handlers/CertificateRetrievalHandler.cs
index 25aed7f..5438160 100644
--- a/src/opencertserver.ca.server/Handlers/CertificateRetrievalHandler.cs
+++ b/src/opencertserver.ca.server/Handlers/CertificateRetrievalHandler.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Ca.Server.Handlers;
+using System.Diagnostics;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
@@ -11,27 +12,45 @@ public static class CertificateRetrievalHandler
public static async Task HandleGet(HttpContext context)
{
- var store = context.RequestServices.GetRequiredService();
- var thumbprints = context.Request.Query["thumbprint"];
- var ids = context.Request.Query["id"];
- var thumbCerts = store.GetCertificatesByThumbprint(
- thumbprints.Where(s => s != null)
- .Select(tp => tp.AsMemory()));
- var idCerts = store.GetCertificatesById(
- context.RequestAborted,
- ids.Where(s => s != null)
- .Select(tp => new ReadOnlyMemory(Convert.FromHexString(tp!))));
+ CaInstruments.CertRetrievalRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.CertificateRetrieval);
+ try
+ {
+ var store = context.RequestServices.GetRequiredService();
+ var thumbprints = context.Request.Query["thumbprint"];
+ var ids = context.Request.Query["id"];
+ var thumbCerts = store.GetCertificatesByThumbprint(
+ thumbprints.Where(s => s != null)
+ .Select(tp => tp.AsMemory()));
+ var idCerts = store.GetCertificatesById(
+ context.RequestAborted,
+ ids.Where(s => s != null)
+ .Select(tp => new ReadOnlyMemory(Convert.FromHexString(tp!))));
+
+ context.Response.ContentType = "application/x-pem-file";
+ var bodyWriter = context.Response.BodyWriter;
+ await foreach (var cert in thumbCerts.Concat(idCerts).ConfigureAwait(false))
+ {
+ var pem = PemEncoding.WriteUtf8("CERTIFICATE"u8, cert.RawData);
+ await bodyWriter.WriteAsync(pem).ConfigureAwait(false);
+ await bodyWriter.WriteAsync(NewLine).ConfigureAwait(false);
+ }
- context.Response.ContentType = "application/x-pem-file";
- var bodyWriter = context.Response.BodyWriter;
- await foreach (var cert in thumbCerts.Concat(idCerts).ConfigureAwait(false))
+ await bodyWriter.FlushAsync().ConfigureAwait(false);
+ await bodyWriter.CompleteAsync().ConfigureAwait(false);
+ CaInstruments.CertRetrievalSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+ catch (Exception ex)
{
- var pem = PemEncoding.WriteUtf8("CERTIFICATE"u8, cert.RawData);
- await bodyWriter.WriteAsync(pem).ConfigureAwait(false);
- await bodyWriter.WriteAsync(NewLine).ConfigureAwait(false);
+ CaInstruments.CertRetrievalFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ CaInstruments.CertRetrievalDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
}
-
- await bodyWriter.FlushAsync().ConfigureAwait(false);
- await bodyWriter.CompleteAsync().ConfigureAwait(false);
}
}
diff --git a/src/opencertserver.ca.server/Handlers/CrlHandler.cs b/src/opencertserver.ca.server/Handlers/CrlHandler.cs
index 3761f7a..30ab49a 100644
--- a/src/opencertserver.ca.server/Handlers/CrlHandler.cs
+++ b/src/opencertserver.ca.server/Handlers/CrlHandler.cs
@@ -2,6 +2,7 @@
namespace OpenCertServer.Ca.Server.Handlers;
+using System.Diagnostics;
using Microsoft.AspNetCore.Http;
using OpenCertServer.Ca.Utils.Ca;
@@ -14,7 +15,30 @@ public static Task Handle(ICertificateAuthority ca)
public static async Task HandleProfile([FromRoute] string profileName, ICertificateAuthority ca)
{
- var crl = await ca.GetRevocationList(profileName).ConfigureAwait(false);
- return Results.Bytes(crl, "application/pkix-crl");
+ CaInstruments.CrlRequests.Add(1);
+ CaInstruments.CrlGenerationRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.CrlRequest);
+ try
+ {
+ var crl = await ca.GetRevocationList(profileName).ConfigureAwait(false);
+ CaInstruments.CrlSuccesses.Add(1);
+ CaInstruments.CrlGenerationSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ return Results.Bytes(crl, "application/pkix-crl");
+ }
+ catch (Exception ex)
+ {
+ CaInstruments.CrlFailures.Add(1);
+ CaInstruments.CrlGenerationFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ var elapsed = Stopwatch.GetElapsedTime(sw).TotalSeconds;
+ CaInstruments.CrlDuration.Record(elapsed);
+ CaInstruments.CrlGenerationDuration.Record(elapsed);
+ }
}
}
diff --git a/src/opencertserver.ca.server/Handlers/CsrHandler.cs b/src/opencertserver.ca.server/Handlers/CsrHandler.cs
index 8a720f4..855e903 100644
--- a/src/opencertserver.ca.server/Handlers/CsrHandler.cs
+++ b/src/opencertserver.ca.server/Handlers/CsrHandler.cs
@@ -4,6 +4,7 @@
namespace OpenCertServer.Ca.Server.Handlers;
+using System.Diagnostics;
using System.Net;
using Microsoft.AspNetCore.Http;
using OpenCertServer.Ca.Utils.Ca;
@@ -18,20 +19,40 @@ public static async Task Handle(
[FromBody] Stream body,
CancellationToken cancellationToken)
{
- using var reader = new StreamReader(body);
- var csrPem = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- var certResponse = await ca.SignCertificateRequestPem(csrPem, profileName, user.Identity as ClaimsIdentity,
- cancellationToken: cancellationToken);
- if (certResponse is SignCertificateResponse.Success success)
+ CaInstruments.CsrRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.CsrSign);
+ try
{
- return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemMimeType);
- }
+ using var reader = new StreamReader(body);
+ var csrPem = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ var certResponse = await ca.SignCertificateRequestPem(csrPem, profileName, user.Identity as ClaimsIdentity,
+ cancellationToken: cancellationToken);
+ if (certResponse is SignCertificateResponse.Success success)
+ {
+ CaInstruments.CsrSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemMimeType);
+ }
- var error = (SignCertificateResponse.Error)certResponse;
- return Results.Text(
- string.Join(Environment.NewLine, error.Errors),
- Constants.TextPlainMimeType,
- Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
+ var error = (SignCertificateResponse.Error)certResponse;
+ CaInstruments.CsrFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error);
+ return Results.Text(
+ string.Join(Environment.NewLine, error.Errors),
+ Constants.TextPlainMimeType,
+ Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
+ }
+ catch (Exception ex)
+ {
+ CaInstruments.CsrFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ CaInstruments.CsrDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ }
}
}
diff --git a/src/opencertserver.ca.server/Handlers/InventoryHandler.cs b/src/opencertserver.ca.server/Handlers/InventoryHandler.cs
index 7b9e2fa..f02bed3 100644
--- a/src/opencertserver.ca.server/Handlers/InventoryHandler.cs
+++ b/src/opencertserver.ca.server/Handlers/InventoryHandler.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Ca.Server.Handlers;
+using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
@@ -9,14 +10,33 @@ public static class InventoryHandler
{
public static async Task Handle(HttpContext context)
{
- var store = context.RequestServices.GetRequiredService();
- var page = context.Request.Query.ContainsKey("page")
- ? int.TryParse(context.Request.Query["page"], out var p) ? p : 0
- : 0;
- var inventory = await store.GetInventory(page).ToArrayAsync().ConfigureAwait(false);
- context.Response.ContentType = "application/json";
- await JsonSerializer.SerializeAsync(context.Response.Body, inventory,
- CaServerSerializerContext.Default.CertificateItemInfoArray).ConfigureAwait(false);
- await context.Response.Body.FlushAsync().ConfigureAwait(false);
+ CaInstruments.InventoryRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.Inventory);
+ try
+ {
+ var store = context.RequestServices.GetRequiredService();
+ var page = context.Request.Query.ContainsKey("page")
+ ? int.TryParse(context.Request.Query["page"], out var p) ? p : 0
+ : 0;
+ var inventory = await store.GetInventory(page).ToArrayAsync().ConfigureAwait(false);
+ context.Response.ContentType = "application/json";
+ await JsonSerializer.SerializeAsync(context.Response.Body, inventory,
+ CaServerSerializerContext.Default.CertificateItemInfoArray).ConfigureAwait(false);
+ await context.Response.Body.FlushAsync().ConfigureAwait(false);
+ CaInstruments.InventorySuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+ catch (Exception ex)
+ {
+ CaInstruments.InventoryFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ CaInstruments.InventoryDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ }
}
+
}
diff --git a/src/opencertserver.ca.server/Handlers/OcspHandler.cs b/src/opencertserver.ca.server/Handlers/OcspHandler.cs
index 63003fa..bb9d815 100644
--- a/src/opencertserver.ca.server/Handlers/OcspHandler.cs
+++ b/src/opencertserver.ca.server/Handlers/OcspHandler.cs
@@ -1,6 +1,6 @@
namespace OpenCertServer.Ca.Server.Handlers;
-using System.Buffers.Text;
+using System.Diagnostics;
using System.Formats.Asn1;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
@@ -15,6 +15,9 @@ public static class OcspHandler
{
public static async Task Handle(HttpContext context)
{
+ CaInstruments.OcspRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.OcspRequest);
var cancellationToken = context.RequestAborted;
byte[] requestBytes;
@@ -25,6 +28,9 @@ public static async Task Handle(HttpContext context)
StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ CaInstruments.OcspFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, "Invalid content type");
+ CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
return;
}
@@ -35,6 +41,9 @@ public static async Task Handle(HttpContext context)
else
{
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
+ CaInstruments.OcspFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, "Method not allowed");
+ CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
return;
}
@@ -43,22 +52,31 @@ public static async Task Handle(HttpContext context)
response.ContentType = "application/ocsp-response";
await response.Body.WriteAsync(responseBytes, cancellationToken).ConfigureAwait(false);
await response.CompleteAsync().ConfigureAwait(false);
+ CaInstruments.OcspSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
}
public static async Task HandleGet(HttpContext context)
{
+ CaInstruments.OcspRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.OcspRequest);
var cancellationToken = context.RequestAborted;
var encodedRequest = context.Request.RouteValues["requestEncoded"] as string;
if (string.IsNullOrWhiteSpace(encodedRequest))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ CaInstruments.OcspFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, "Missing encoded request");
+ CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
return;
}
byte[] requestBytes;
try
{
- requestBytes = Base64Url.DecodeFromChars(encodedRequest.AsSpan());
+ requestBytes = System.Buffers.Text.Base64Url.DecodeFromChars(encodedRequest.AsSpan());
}
catch
{
@@ -68,6 +86,9 @@ public static async Task HandleGet(HttpContext context)
context.Response.ContentType = "application/ocsp-response";
await context.Response.Body.WriteAsync(w.Encode(), cancellationToken).ConfigureAwait(false);
await context.Response.CompleteAsync().ConfigureAwait(false);
+ CaInstruments.OcspFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, "Malformed request");
+ CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
return;
}
@@ -76,6 +97,9 @@ public static async Task HandleGet(HttpContext context)
response.ContentType = "application/ocsp-response";
await response.Body.WriteAsync(responseBytes, cancellationToken).ConfigureAwait(false);
await response.CompleteAsync().ConfigureAwait(false);
+ CaInstruments.OcspSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
}
private static async Task ProcessRequestAsync(
diff --git a/src/opencertserver.ca.server/Handlers/RevocationHandler.cs b/src/opencertserver.ca.server/Handlers/RevocationHandler.cs
index a7ab2c8..7d5fe4f 100644
--- a/src/opencertserver.ca.server/Handlers/RevocationHandler.cs
+++ b/src/opencertserver.ca.server/Handlers/RevocationHandler.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Ca.Server.Handlers;
+using System.Diagnostics;
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
@@ -13,40 +14,76 @@ public static class RevocationHandler
{
public static async Task Handle(HttpContext context)
{
- var clientCert = await context.Connection.GetClientCertificateAsync().ConfigureAwait(false);
- if (clientCert == null)
+ CaInstruments.RevocationRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.Revoke);
+ try
{
- context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
+ var clientCert = await context.Connection.GetClientCertificateAsync().ConfigureAwait(false);
+ if (clientCert == null)
+ {
+ context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
+ await context.Response.CompleteAsync().ConfigureAwait(false);
+ CaInstruments.RevocationFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, "No client certificate");
+ CaInstruments.RevocationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return;
+ }
+
+ var signature = context.Request.Query["signature"].ToString().Base64DecodeBytes();
+ var serialNumberHex = context.Request.Query["sn"].ToString();
+ var asymmetricAlgorithm = clientCert.GetRSAPublicKey() ?? (AsymmetricAlgorithm?)clientCert.GetECDsaPublicKey();
+ var reasonString = context.Request.Query["reason"];
+ if (asymmetricAlgorithm == null
+ || !asymmetricAlgorithm.VerifySignature(
+ Encoding.UTF8.GetBytes(serialNumberHex + reasonString),
+ signature,
+ HashAlgorithmName.SHA256))
+ {
+ context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
+ await context.Response.CompleteAsync().ConfigureAwait(false);
+ CaInstruments.RevocationFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, "Signature verification failed");
+ CaInstruments.RevocationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return;
+ }
+
+ if (string.IsNullOrEmpty(serialNumberHex)
+ || !Enum.TryParse(reasonString.ToString(), true, out X509RevocationReason reason))
+ {
+ context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
+ await context.Response.CompleteAsync().ConfigureAwait(false);
+ CaInstruments.RevocationFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, "Invalid parameters");
+ CaInstruments.RevocationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return;
+ }
+
+ var ca = context.RequestServices.GetRequiredService();
+ var result = await ca.RevokeCertificate(serialNumberHex, reason).ConfigureAwait(false);
+ context.Response.StatusCode = result ? (int)HttpStatusCode.OK : (int)HttpStatusCode.NotFound;
await context.Response.CompleteAsync().ConfigureAwait(false);
- return;
+
+ if (result)
+ {
+ CaInstruments.RevocationSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+ else
+ {
+ CaInstruments.RevocationFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, "Certificate not found");
+ }
}
- var signature = context.Request.Query["signature"].ToString().Base64DecodeBytes();
- var serialNumberHex = context.Request.Query["sn"].ToString();
- var asymmetricAlgorithm = clientCert.GetRSAPublicKey() ?? (AsymmetricAlgorithm?)clientCert.GetECDsaPublicKey();
- var reasonString = context.Request.Query["reason"];
- if (asymmetricAlgorithm == null
- || !asymmetricAlgorithm.VerifySignature(
- Encoding.UTF8.GetBytes(serialNumberHex + reasonString),
- signature,
- HashAlgorithmName.SHA256))
+ catch (Exception ex)
{
- context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
- await context.Response.CompleteAsync().ConfigureAwait(false);
- return;
+ CaInstruments.RevocationFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
}
-
- if (string.IsNullOrEmpty(serialNumberHex)
- || !Enum.TryParse(reasonString.ToString(), true,
- out X509RevocationReason reason))
+ finally
{
- context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
- await context.Response.CompleteAsync().ConfigureAwait(false);
- return;
+ CaInstruments.RevocationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
}
-
- var ca = context.RequestServices.GetRequiredService();
- var result = await ca.RevokeCertificate(serialNumberHex, reason).ConfigureAwait(false);
- context.Response.StatusCode = result ? (int)HttpStatusCode.OK : (int)HttpStatusCode.NotFound;
- await context.Response.CompleteAsync().ConfigureAwait(false);
}
}
diff --git a/src/opencertserver.ca.server/MetricNames.cs b/src/opencertserver.ca.server/MetricNames.cs
new file mode 100644
index 0000000..25ef39f
--- /dev/null
+++ b/src/opencertserver.ca.server/MetricNames.cs
@@ -0,0 +1,53 @@
+namespace OpenCertServer.Ca.Server;
+
+///
+/// Centralised metric name constants for all CA/OCSP/CRL OpenTelemetry instruments.
+/// All metric instrument creation MUST reference these constants.
+///
+internal static class MetricNames
+{
+ internal const string MeterName = "opencertserver.ca";
+
+ // OCSP – RFC 6960
+ internal const string OcspRequests = "opencertserver.ocsp.request.requests";
+ internal const string OcspSuccesses = "opencertserver.ocsp.request.successes";
+ internal const string OcspFailures = "opencertserver.ocsp.request.failures";
+ internal const string OcspDuration = "opencertserver.ocsp.request.duration";
+
+ // CRL requests – RFC 5280
+ internal const string CrlRequests = "opencertserver.crl.request.requests";
+ internal const string CrlSuccesses = "opencertserver.crl.request.successes";
+ internal const string CrlFailures = "opencertserver.crl.request.failures";
+ internal const string CrlDuration = "opencertserver.crl.request.duration";
+
+ // CRL generation
+ internal const string CrlGenerationRequests = "opencertserver.crl.generation.requests";
+ internal const string CrlGenerationSuccesses = "opencertserver.crl.generation.successes";
+ internal const string CrlGenerationFailures = "opencertserver.crl.generation.failures";
+ internal const string CrlGenerationDuration = "opencertserver.crl.generation.duration";
+
+ // /ca/csr (CSR signing)
+ internal const string CsrRequests = "opencertserver.ca.csr.requests";
+ internal const string CsrSuccesses = "opencertserver.ca.csr.successes";
+ internal const string CsrFailures = "opencertserver.ca.csr.failures";
+ internal const string CsrDuration = "opencertserver.ca.csr.duration";
+
+ // /ca/revoke
+ internal const string RevocationRequests = "opencertserver.ca.revoke.requests";
+ internal const string RevocationSuccesses = "opencertserver.ca.revoke.successes";
+ internal const string RevocationFailures = "opencertserver.ca.revoke.failures";
+ internal const string RevocationDuration = "opencertserver.ca.revoke.duration";
+
+ // /ca/inventory
+ internal const string InventoryRequests = "opencertserver.ca.inventory.requests";
+ internal const string InventorySuccesses = "opencertserver.ca.inventory.successes";
+ internal const string InventoryFailures = "opencertserver.ca.inventory.failures";
+ internal const string InventoryDuration = "opencertserver.ca.inventory.duration";
+
+ // /ca/certificates (retrieval by thumbprint / id)
+ internal const string CertRetrievalRequests = "opencertserver.ca.certretrieval.requests";
+ internal const string CertRetrievalSuccesses = "opencertserver.ca.certretrieval.successes";
+ internal const string CertRetrievalFailures = "opencertserver.ca.certretrieval.failures";
+ internal const string CertRetrievalDuration = "opencertserver.ca.certretrieval.duration";
+}
+
diff --git a/src/opencertserver.ca.server/TagKeys.cs b/src/opencertserver.ca.server/TagKeys.cs
new file mode 100644
index 0000000..6cb66e4
--- /dev/null
+++ b/src/opencertserver.ca.server/TagKeys.cs
@@ -0,0 +1,27 @@
+namespace OpenCertServer.Ca.Server;
+
+///
+/// Centralised tag (attribute) key constants for all CA/OCSP/CRL OpenTelemetry measurements.
+/// Use these keys when adding dimensions to counters, histograms and spans.
+///
+internal static class TagKeys
+{
+ /// CA profile name (empty string for the default profile).
+ internal const string Profile = "ca.profile";
+
+ /// OCSP response status, e.g. "good", "revoked", "unknown".
+ internal const string OcspStatus = "ocsp.status";
+
+ /// X.509 revocation reason code.
+ internal const string RevocationReason = "ca.revocation.reason";
+
+ ///
+ /// Structured error type; set to the exception class name or a short error code
+ /// on all failure counter and span recordings.
+ ///
+ internal const string ErrorType = "error.type";
+
+ /// HTTP response status code recorded on non-2xx result paths.
+ internal const string HttpStatusCode = "http.response.status_code";
+}
+
diff --git a/src/opencertserver.est.server/ActivityNames.cs b/src/opencertserver.est.server/ActivityNames.cs
new file mode 100644
index 0000000..9b726f8
--- /dev/null
+++ b/src/opencertserver.est.server/ActivityNames.cs
@@ -0,0 +1,15 @@
+namespace OpenCertServer.Est.Server;
+
+///
+/// Centralised activity (span) name constants for all EST OpenTelemetry traces.
+/// All spans MUST reference these constants; never use inline string literals for activity names.
+///
+internal static class ActivityNames
+{
+ internal const string CaCerts = "opencertserver.est.cacerts";
+ internal const string SimpleEnroll = "opencertserver.est.simpleenroll";
+ internal const string SimpleReEnroll = "opencertserver.est.simplereenroll";
+ internal const string CsrAttrs = "opencertserver.est.csrattrs";
+ internal const string ServerKeyGen = "opencertserver.est.serverkeygen";
+}
+
diff --git a/src/opencertserver.est.server/EstInstruments.cs b/src/opencertserver.est.server/EstInstruments.cs
new file mode 100644
index 0000000..cc39510
--- /dev/null
+++ b/src/opencertserver.est.server/EstInstruments.cs
@@ -0,0 +1,42 @@
+namespace OpenCertServer.Est.Server;
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+/// OpenTelemetry instruments for all EST request handlers (RFC 7030).
+internal static class EstInstruments
+{
+ private static readonly Meter Meter = new(MetricNames.MeterName, "1.0.0");
+
+ internal static readonly ActivitySource ActivitySource = new(MetricNames.MeterName, "1.0.0");
+
+ // /cacerts
+ internal static readonly Counter CaCertsRequests = Meter.CreateCounter (MetricNames.CaCertsRequests, description: "Total /cacerts requests");
+ internal static readonly Counter CaCertsSuccesses = Meter.CreateCounter (MetricNames.CaCertsSuccesses, description: "Successful /cacerts responses");
+ internal static readonly Counter CaCertsFailures = Meter.CreateCounter (MetricNames.CaCertsFailures, description: "Failed /cacerts responses");
+ internal static readonly Histogram CaCertsDuration = Meter.CreateHistogram (MetricNames.CaCertsDuration, "s", "Duration of /cacerts requests");
+
+ // /simpleenroll
+ internal static readonly Counter SimpleEnrollRequests = Meter.CreateCounter (MetricNames.SimpleEnrollRequests, description: "Total /simpleenroll requests");
+ internal static readonly Counter SimpleEnrollSuccesses = Meter.CreateCounter (MetricNames.SimpleEnrollSuccesses, description: "Successful /simpleenroll responses");
+ internal static readonly Counter SimpleEnrollFailures = Meter.CreateCounter (MetricNames.SimpleEnrollFailures, description: "Failed /simpleenroll responses");
+ internal static readonly Histogram SimpleEnrollDuration = Meter.CreateHistogram (MetricNames.SimpleEnrollDuration, "s", "Duration of /simpleenroll requests");
+
+ // /simplereenroll
+ internal static readonly Counter SimpleReEnrollRequests = Meter.CreateCounter (MetricNames.SimpleReEnrollRequests, description: "Total /simplereenroll requests");
+ internal static readonly Counter SimpleReEnrollSuccesses = Meter.CreateCounter (MetricNames.SimpleReEnrollSuccesses, description: "Successful /simplereenroll responses");
+ internal static readonly Counter SimpleReEnrollFailures = Meter.CreateCounter (MetricNames.SimpleReEnrollFailures, description: "Failed /simplereenroll responses");
+ internal static readonly Histogram SimpleReEnrollDuration = Meter.CreateHistogram (MetricNames.SimpleReEnrollDuration, "s", "Duration of /simplereenroll requests");
+
+ // /csrattrs
+ internal static readonly Counter CsrAttrsRequests = Meter.CreateCounter (MetricNames.CsrAttrsRequests, description: "Total /csrattrs requests");
+ internal static readonly Counter CsrAttrsSuccesses = Meter.CreateCounter (MetricNames.CsrAttrsSuccesses, description: "Successful /csrattrs responses");
+ internal static readonly Counter CsrAttrsFailures = Meter.CreateCounter (MetricNames.CsrAttrsFailures, description: "Failed /csrattrs responses");
+ internal static readonly Histogram CsrAttrsDuration = Meter.CreateHistogram (MetricNames.CsrAttrsDuration, "s", "Duration of /csrattrs requests");
+
+ // /serverkeygen
+ internal static readonly Counter ServerKeyGenRequests = Meter.CreateCounter (MetricNames.ServerKeyGenRequests, description: "Total /serverkeygen requests");
+ internal static readonly Counter ServerKeyGenSuccesses = Meter.CreateCounter (MetricNames.ServerKeyGenSuccesses, description: "Successful /serverkeygen responses");
+ internal static readonly Counter ServerKeyGenFailures = Meter.CreateCounter (MetricNames.ServerKeyGenFailures, description: "Failed /serverkeygen responses");
+ internal static readonly Histogram ServerKeyGenDuration = Meter.CreateHistogram (MetricNames.ServerKeyGenDuration, "s", "Duration of /serverkeygen requests");
+}
diff --git a/src/opencertserver.est.server/Handlers/CaCertHandler.cs b/src/opencertserver.est.server/Handlers/CaCertHandler.cs
index 781e0b3..4bef53d 100644
--- a/src/opencertserver.est.server/Handlers/CaCertHandler.cs
+++ b/src/opencertserver.est.server/Handlers/CaCertHandler.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Est.Server.Handlers;
+using System.Diagnostics;
using System.Formats.Asn1;
using System.Net;
using Microsoft.AspNetCore.Http;
@@ -21,15 +22,34 @@ public static async Task HandleProfile(
EstPublishedCertificatesResolver certificates,
CancellationToken cancellationToken = default)
{
- var export = await certificates(profileName, cancellationToken).ConfigureAwait(false);
- var signedData = new SignedData(version: 1, certificates: export.ToArray());
- var contentInfo = new CmsContentInfo(
- Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName),
- signedData);
- var writer = new AsnWriter(AsnEncodingRules.DER);
- contentInfo.Encode(writer);
- var contentBytes = writer.Encode();
- return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly,
- statusCode: (int)HttpStatusCode.OK);
+ EstInstruments.CaCertsRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.CaCerts);
+ activity?.AddTag(TagKeys.Profile, profileName);
+ try
+ {
+ var export = await certificates(profileName, cancellationToken).ConfigureAwait(false);
+ var signedData = new SignedData(version: 1, certificates: export.ToArray());
+ var contentInfo = new CmsContentInfo(
+ Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName),
+ signedData);
+ var writer = new AsnWriter(AsnEncodingRules.DER);
+ contentInfo.Encode(writer);
+ var contentBytes = writer.Encode();
+ EstInstruments.CaCertsSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly,
+ statusCode: (int)HttpStatusCode.OK);
+ }
+ catch (Exception ex)
+ {
+ EstInstruments.CaCertsFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ EstInstruments.CaCertsDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ }
}
}
diff --git a/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs b/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs
index dc15855..e79ff01 100644
--- a/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs
+++ b/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs
@@ -2,6 +2,7 @@
namespace OpenCertServer.Est.Server.Handlers;
+using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
@@ -21,7 +22,26 @@ public static async Task HandleProfile(
ICsrTemplateLoader loader,
CancellationToken cancellationToken = default)
{
- var attributes = await loader.GetTemplate(profileName, user, cancellationToken).ConfigureAwait(false);
- return new CertificateSigningRequestTemplateResult(attributes);
+ EstInstruments.CsrAttrsRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.CsrAttrs);
+ activity?.AddTag(TagKeys.Profile, profileName);
+ try
+ {
+ var attributes = await loader.GetTemplate(profileName, user, cancellationToken).ConfigureAwait(false);
+ EstInstruments.CsrAttrsSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ return new CertificateSigningRequestTemplateResult(attributes);
+ }
+ catch (Exception ex)
+ {
+ EstInstruments.CsrAttrsFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ EstInstruments.CsrAttrsDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ }
}
}
diff --git a/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs b/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs
index 3fb6433..981f1bc 100644
--- a/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs
+++ b/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using OpenCertServer.Ca.Utils.Ca;
@@ -40,61 +41,96 @@ public static async Task HandleProfile(
Stream body,
CancellationToken cancellationToken)
{
+ EstInstruments.ServerKeyGenRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.ServerKeyGen);
+ activity?.AddTag(TagKeys.Profile, profileName);
+ IResult result;
try
{
- using var reader = new StreamReader(body, Encoding.UTF8);
- var request = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- var signingRequest = PemEncoding.TryFind(request, out _)
- ? CertificateRequest.LoadSigningRequestPem(
- request,
- HashAlgorithmName.SHA256)
- : CertificateRequest.LoadSigningRequest(
- request.Base64DecodeBytes(),
- HashAlgorithmName.SHA256,
- CertificateRequestLoadOptions.SkipSignatureValidation,
- RSASignaturePadding.Pss);
-
- var encryptedKeyDelivery = GetRequestedEncryptedKeyDelivery(httpRequest);
- if (encryptedKeyDelivery.ErrorResult != null)
- {
- return encryptedKeyDelivery.ErrorResult;
- }
+ result = await Core().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ EstInstruments.ServerKeyGenFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ EstInstruments.ServerKeyGenDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
+ }
- var privateKey = signingRequest.PublicKey.Oid.Value switch
+ var statusCode = (result as IStatusCodeHttpResult)?.StatusCode ?? 200;
+ if (statusCode >= 400)
+ {
+ EstInstruments.ServerKeyGenFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error);
+ }
+ else
+ {
+ EstInstruments.ServerKeyGenSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+
+ EstInstruments.ServerKeyGenDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return result;
+
+ async Task Core()
+ {
+ try
{
- Oids.Rsa => CreateServerSideRsaRequest(signingRequest),
- Oids.EcPublicKey => CreateServerSideEcRequest(signingRequest),
- _ => throw new NotSupportedException(
- $"Server-side key generation does not support CSR public key algorithm '{signingRequest.PublicKey.Oid.Value}'.")
- };
-
- var newCert =
- await certificateAuthority.SignCertificateRequest(privateKey.Request, profileName,
- user.Identity as ClaimsIdentity, cancellationToken: cancellationToken).ConfigureAwait(false);
- if (newCert is SignCertificateResponse.Success success)
+ using var reader = new StreamReader(body, Encoding.UTF8);
+ var request = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ var signingRequest = PemEncoding.TryFind(request, out _)
+ ? CertificateRequest.LoadSigningRequestPem(
+ request,
+ HashAlgorithmName.SHA256)
+ : CertificateRequest.LoadSigningRequest(
+ request.Base64DecodeBytes(),
+ HashAlgorithmName.SHA256,
+ CertificateRequestLoadOptions.SkipSignatureValidation,
+ RSASignaturePadding.Pss);
+
+ var encryptedKeyDelivery = GetRequestedEncryptedKeyDelivery(httpRequest);
+ if (encryptedKeyDelivery.ErrorResult != null)
+ {
+ return encryptedKeyDelivery.ErrorResult;
+ }
+
+ var privateKey = signingRequest.PublicKey.Oid.Value switch
+ {
+ Oids.Rsa => CreateServerSideRsaRequest(signingRequest),
+ Oids.EcPublicKey => CreateServerSideEcRequest(signingRequest),
+ _ => throw new NotSupportedException(
+ $"Server-side key generation does not support CSR public key algorithm '{signingRequest.PublicKey.Oid.Value}'.")
+ };
+
+ var newCert =
+ await certificateAuthority.SignCertificateRequest(privateKey.Request, profileName,
+ user.Identity as ClaimsIdentity, cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (newCert is SignCertificateResponse.Success success)
+ {
+ var mpr = new MultipartContent();
+ mpr.Add(encryptedKeyDelivery.UseEncryptedKeyPart
+ ? new EstMultipartBase64Content(
+ privateKey.Pkcs8.Base64Encode(),
+ Constants.PemMimeType,
+ smimeType: "server-generated-key")
+ : new EstMultipartBase64Content(privateKey.Pkcs8.Base64Encode(), Constants.Pkcs8));
+ mpr.Add(new EstMultipartBase64Content(CreateCertsOnlyResponse(success.Certificate), Constants.PemMimeType));
+ return new MultipartContentResult(mpr);
+ }
+
+ var error = (SignCertificateResponse.Error)newCert;
+ return Results.Text(
+ string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType,
+ Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
+ }
+ catch (Exception)
{
- var mpr = new MultipartContent();
- mpr.Add(encryptedKeyDelivery.UseEncryptedKeyPart
- ? new EstMultipartBase64Content(
- privateKey.Pkcs8.Base64Encode(),
- Constants.PemMimeType,
- smimeType: "server-generated-key")
- : new EstMultipartBase64Content(privateKey.Pkcs8.Base64Encode(), Constants.Pkcs8));
- mpr.Add(new EstMultipartBase64Content(CreateCertsOnlyResponse(success.Certificate), Constants.PemMimeType));
- return new MultipartContentResult(mpr);
+ return Results.Text(
+ "An error occurred while processing the request.", Constants.TextPlainMimeType, Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
}
-
- var error = (SignCertificateResponse.Error)newCert;
- return Results.Text(
- string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType,
- Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
- }
- catch (Exception)
- {
- return Results.Text(
- "An error occurred while processing the request.", Constants.TextPlainMimeType, Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
}
}
diff --git a/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs b/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs
index ae44fed..c169e7a 100644
--- a/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs
+++ b/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs
@@ -2,6 +2,7 @@
namespace OpenCertServer.Est.Server.Handlers;
+using System.Diagnostics;
using System.Formats.Asn1;
using System.IO;
using System.Net;
@@ -38,84 +39,119 @@ public static async Task HandleProfile(
IManualAuthorizationStrategy manualAuthorizationStrategy,
CancellationToken cancellationToken)
{
- var body = httpRequest.Body;
- var responseType = httpRequest.GetTypedHeaders().Accept;
- using var reader = new StreamReader(body, Encoding.UTF8);
- var requestContent = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ EstInstruments.SimpleEnrollRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.SimpleEnroll);
+ activity?.AddTag(TagKeys.Profile, profileName);
+ IResult result;
try
{
- requestContent = requestContent.NormalizeBase64();
+ result = await Core().ConfigureAwait(false);
}
- catch (FormatException)
+ catch (Exception ex)
{
+ EstInstruments.SimpleEnrollFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ EstInstruments.SimpleEnrollDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
}
- catch (InvalidOperationException o)
+
+ var statusCode = (result as IStatusCodeHttpResult)?.StatusCode ?? 200;
+ if (statusCode >= 400)
{
- return Results.Text(o.Message, Constants.TextPlainMimeType, Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
+ EstInstruments.SimpleEnrollFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error);
}
-
- if (!requestContent.TryVerifyTlsUniqueValue(out var proofOfPossessionError))
+ else
{
- return Results.Text(proofOfPossessionError, Constants.TextPlainMimeType, Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
+ EstInstruments.SimpleEnrollSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
}
- var csrDer = Convert.FromBase64String(requestContent);
- var csr = CertificateRequest.LoadSigningRequest(
- csrDer,
- HashAlgorithmName.SHA256,
- signerSignaturePadding: RSASignaturePadding.Pss);
+ EstInstruments.SimpleEnrollDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return result;
- if (manualAuthorizationStrategy.TryGetPendingAuthorization(
- httpRequest,
- user,
- csr,
- out var retryAfter,
- out var pendingMessage))
+ async Task Core()
{
- return new RetryAfterResult(retryAfter, pendingMessage);
- }
+ var body = httpRequest.Body;
+ var responseType = httpRequest.GetTypedHeaders().Accept;
+ using var reader = new StreamReader(body, Encoding.UTF8);
+ var requestContent = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ requestContent = requestContent.NormalizeBase64();
+ }
+ catch (FormatException)
+ {
+ }
+ catch (InvalidOperationException o)
+ {
+ return Results.Text(o.Message, Constants.TextPlainMimeType, Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
+ }
- SignCertificateResponse newCert;
- try
- {
- newCert = await certificateAuthority.SignCertificateRequest(
+ if (!requestContent.TryVerifyTlsUniqueValue(out var proofOfPossessionError))
+ {
+ return Results.Text(proofOfPossessionError, Constants.TextPlainMimeType, Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
+ }
+
+ var csrDer = Convert.FromBase64String(requestContent);
+ var csr = CertificateRequest.LoadSigningRequest(
+ csrDer,
+ HashAlgorithmName.SHA256,
+ signerSignaturePadding: RSASignaturePadding.Pss);
+
+ if (manualAuthorizationStrategy.TryGetPendingAuthorization(
+ httpRequest,
+ user,
csr,
- profileName,
- user?.Identity as ClaimsIdentity,
- cancellationToken: cancellationToken).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- return Results.Text($"The enrollment CSR could not be parsed: {ex.Message}", Constants.TextPlainMimeType,
- Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
- }
+ out var retryAfter,
+ out var pendingMessage))
+ {
+ return new RetryAfterResult(retryAfter, pendingMessage);
+ }
- if (newCert is SignCertificateResponse.Success success)
- {
- // This is a deviation from the RFC but is easier to parse.
- if (responseType.Any(x =>
- x.MediaType.HasValue &&
- x.MediaType.Value.Equals(Constants.PemFile, StringComparison.OrdinalIgnoreCase)))
+ SignCertificateResponse newCert;
+ try
{
- return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemFile);
+ newCert = await certificateAuthority.SignCertificateRequest(
+ csr,
+ profileName,
+ user?.Identity as ClaimsIdentity,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ return Results.Text($"The enrollment CSR could not be parsed: {ex.Message}", Constants.TextPlainMimeType,
+ Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
}
- X509Certificate2[] content = [success.Certificate];
- var signedResponse = new SignedData(version: 1, certificates: content);
- var contentInfo = new CmsContentInfo(
- Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName),
- signedResponse);
- var writer = new AsnWriter(AsnEncodingRules.DER);
- contentInfo.Encode(writer);
- var contentBytes = writer.Encode();
- return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly);
- }
+ if (newCert is SignCertificateResponse.Success success)
+ {
+ // This is a deviation from the RFC but is easier to parse.
+ if (responseType.Any(x =>
+ x.MediaType.HasValue &&
+ x.MediaType.Value.Equals(Constants.PemFile, StringComparison.OrdinalIgnoreCase)))
+ {
+ return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemFile);
+ }
- var error = (SignCertificateResponse.Error)newCert;
- return Results.Text(string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType, Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
+ X509Certificate2[] content = [success.Certificate];
+ var signedResponse = new SignedData(version: 1, certificates: content);
+ var contentInfo = new CmsContentInfo(
+ Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName),
+ signedResponse);
+ var writer = new AsnWriter(AsnEncodingRules.DER);
+ contentInfo.Encode(writer);
+ var contentBytes = writer.Encode();
+ return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly);
+ }
+
+ var error = (SignCertificateResponse.Error)newCert;
+ return Results.Text(string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType, Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
+ }
}
}
diff --git a/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs b/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs
index 779d2b9..074c444 100644
--- a/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs
+++ b/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs
@@ -1,5 +1,6 @@
namespace OpenCertServer.Est.Server.Handlers;
+using System.Diagnostics;
using System.Formats.Asn1;
using System.IO;
using System.Net;
@@ -33,94 +34,129 @@ public static async Task HandleProfile(
[FromRoute] string profileName,
CancellationToken cancellationToken)
{
- var cert = await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false);
- using var reader = new StreamReader(context.Request.Body, Encoding.UTF8);
- var requestBody = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
-
- if (cert == null)
- {
- return Results.Text("A client certificate is required for simple re-enrollment.",
- Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest);
- }
-
- if (!requestBody.TryVerifyTlsUniqueValue(out var proofOfPossessionError))
- {
- return Results.Text(proofOfPossessionError, Constants.TextPlainMimeType, Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
- }
-
- CertificateRequest request;
+ EstInstruments.SimpleReEnrollRequests.Add(1);
+ var sw = Stopwatch.GetTimestamp();
+ using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.SimpleReEnroll);
+ activity?.AddTag(TagKeys.Profile, profileName);
+ IResult result;
try
{
- request = PemEncoding.TryFind(requestBody, out _)
- ? CertificateRequest.LoadSigningRequestPem(
- requestBody,
- HashAlgorithmName.SHA256,
- CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions,
- RSASignaturePadding.Pss)
- : CertificateRequest.LoadSigningRequest(
- requestBody.Base64DecodeBytes(),
- HashAlgorithmName.SHA256,
- CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions,
- RSASignaturePadding.Pss);
+ result = await Core().ConfigureAwait(false);
}
catch (Exception ex)
{
- return Results.Text($"The re-enrollment CSR could not be parsed: {ex.Message}",
- Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest);
+ EstInstruments.SimpleReEnrollFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ EstInstruments.SimpleReEnrollDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ throw;
}
- if (!cert.SubjectName.RawData.AsSpan().SequenceEqual(request.SubjectName.RawData))
+ var statusCode = (result as IStatusCodeHttpResult)?.StatusCode ?? 200;
+ if (statusCode >= 400)
{
- return Results.Text("The re-enrollment CSR Subject must match the current certificate Subject.",
- Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest);
+ EstInstruments.SimpleReEnrollFailures.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Error);
}
-
- var currentSans = cert.Extensions.Where(x => x.Oid?.Value == Oids.SubjectAltName)
- .Select(x => x.RawData)
- .ToHashSet();
- var requestedSans = request.CertificateExtensions.Where(x => x.Oid?.Value == Oids.SubjectAltName)
- .Select(x => x.RawData)
- .ToHashSet();
- if (!currentSans.SetEquals(requestedSans))
+ else
{
- return Results.Text(
- "The re-enrollment CSR SubjectAltName extension must match the current certificate SubjectAltName extension.",
- Constants.TextPlainMimeType,
- Encoding.UTF8,
- (int)HttpStatusCode.BadRequest);
+ EstInstruments.SimpleReEnrollSuccesses.Add(1);
+ activity?.SetStatus(ActivityStatusCode.Ok);
}
- var newCert = await certificateAuthority.SignCertificateRequest(
- request,
- profileName,
- user.Identity as ClaimsIdentity,
- cert,
- cancellationToken: cancellationToken).ConfigureAwait(false);
- if (newCert is not SignCertificateResponse.Success success)
- {
- var error = (SignCertificateResponse.Error)newCert;
- return Results.Text(string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType,
- Encoding.UTF8, (int)HttpStatusCode.BadRequest);
- }
+ EstInstruments.SimpleReEnrollDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds);
+ return result;
- var responseType = context.Request.GetTypedHeaders().Accept;
- // This is a deviation from the RFC but is easier to parse.
- if (responseType.Any(x =>
- x.MediaType.HasValue &&
- x.MediaType.Value.Equals(Constants.PemFile, StringComparison.OrdinalIgnoreCase)))
+ async Task Core()
{
- return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemFile);
- }
+ var cert = await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false);
+ using var reader = new StreamReader(context.Request.Body, Encoding.UTF8);
+ var requestBody = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+
+ if (cert == null)
+ {
+ return Results.Text("A client certificate is required for simple re-enrollment.",
+ Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest);
+ }
+
+ if (!requestBody.TryVerifyTlsUniqueValue(out var proofOfPossessionError))
+ {
+ return Results.Text(proofOfPossessionError, Constants.TextPlainMimeType, Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
+ }
+
+ CertificateRequest request;
+ try
+ {
+ request = PemEncoding.TryFind(requestBody, out _)
+ ? CertificateRequest.LoadSigningRequestPem(
+ requestBody,
+ HashAlgorithmName.SHA256,
+ CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions,
+ RSASignaturePadding.Pss)
+ : CertificateRequest.LoadSigningRequest(
+ requestBody.Base64DecodeBytes(),
+ HashAlgorithmName.SHA256,
+ CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions,
+ RSASignaturePadding.Pss);
+ }
+ catch (Exception ex)
+ {
+ return Results.Text($"The re-enrollment CSR could not be parsed: {ex.Message}",
+ Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest);
+ }
+
+ if (!cert.SubjectName.RawData.AsSpan().SequenceEqual(request.SubjectName.RawData))
+ {
+ return Results.Text("The re-enrollment CSR Subject must match the current certificate Subject.",
+ Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest);
+ }
- X509Certificate2[] content = [success.Certificate, ..success.Issuers];
- var signedResponse = new SignedData(version: 1, certificates: content);
- var contentInfo = new CmsContentInfo(
- Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName),
- signedResponse);
- var writer = new AsnWriter(AsnEncodingRules.DER);
- contentInfo.Encode(writer);
- var contentBytes = writer.Encode();
- return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly);
+ var currentSans = cert.Extensions.Where(x => x.Oid?.Value == Oids.SubjectAltName)
+ .Select(x => x.RawData)
+ .ToHashSet();
+ var requestedSans = request.CertificateExtensions.Where(x => x.Oid?.Value == Oids.SubjectAltName)
+ .Select(x => x.RawData)
+ .ToHashSet();
+ if (!currentSans.SetEquals(requestedSans))
+ {
+ return Results.Text(
+ "The re-enrollment CSR SubjectAltName extension must match the current certificate SubjectAltName extension.",
+ Constants.TextPlainMimeType,
+ Encoding.UTF8,
+ (int)HttpStatusCode.BadRequest);
+ }
+
+ var newCert = await certificateAuthority.SignCertificateRequest(
+ request,
+ profileName,
+ user.Identity as ClaimsIdentity,
+ cert,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (newCert is not SignCertificateResponse.Success success)
+ {
+ var error = (SignCertificateResponse.Error)newCert;
+ return Results.Text(string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType,
+ Encoding.UTF8, (int)HttpStatusCode.BadRequest);
+ }
+
+ var responseType = context.Request.GetTypedHeaders().Accept;
+ // This is a deviation from the RFC but is easier to parse.
+ if (responseType.Any(x =>
+ x.MediaType.HasValue &&
+ x.MediaType.Value.Equals(Constants.PemFile, StringComparison.OrdinalIgnoreCase)))
+ {
+ return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemFile);
+ }
+
+ X509Certificate2[] content = [success.Certificate, ..success.Issuers];
+ var signedResponse = new SignedData(version: 1, certificates: content);
+ var contentInfo = new CmsContentInfo(
+ Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName),
+ signedResponse);
+ var writer = new AsnWriter(AsnEncodingRules.DER);
+ contentInfo.Encode(writer);
+ var contentBytes = writer.Encode();
+ return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly);
+ }
}
}
diff --git a/src/opencertserver.est.server/MetricNames.cs b/src/opencertserver.est.server/MetricNames.cs
new file mode 100644
index 0000000..b5aa878
--- /dev/null
+++ b/src/opencertserver.est.server/MetricNames.cs
@@ -0,0 +1,41 @@
+namespace OpenCertServer.Est.Server;
+
+///
+/// Centralised metric name constants for all EST OpenTelemetry instruments.
+/// All metric instrument creation MUST reference these constants.
+///
+internal static class MetricNames
+{
+ internal const string MeterName = "opencertserver.est";
+
+ // /cacerts
+ internal const string CaCertsRequests = "opencertserver.est.cacerts.requests";
+ internal const string CaCertsSuccesses = "opencertserver.est.cacerts.successes";
+ internal const string CaCertsFailures = "opencertserver.est.cacerts.failures";
+ internal const string CaCertsDuration = "opencertserver.est.cacerts.duration";
+
+ // /simpleenroll
+ internal const string SimpleEnrollRequests = "opencertserver.est.simpleenroll.requests";
+ internal const string SimpleEnrollSuccesses = "opencertserver.est.simpleenroll.successes";
+ internal const string SimpleEnrollFailures = "opencertserver.est.simpleenroll.failures";
+ internal const string SimpleEnrollDuration = "opencertserver.est.simpleenroll.duration";
+
+ // /simplereenroll
+ internal const string SimpleReEnrollRequests = "opencertserver.est.simplereenroll.requests";
+ internal const string SimpleReEnrollSuccesses = "opencertserver.est.simplereenroll.successes";
+ internal const string SimpleReEnrollFailures = "opencertserver.est.simplereenroll.failures";
+ internal const string SimpleReEnrollDuration = "opencertserver.est.simplereenroll.duration";
+
+ // /csrattrs
+ internal const string CsrAttrsRequests = "opencertserver.est.csrattrs.requests";
+ internal const string CsrAttrsSuccesses = "opencertserver.est.csrattrs.successes";
+ internal const string CsrAttrsFailures = "opencertserver.est.csrattrs.failures";
+ internal const string CsrAttrsDuration = "opencertserver.est.csrattrs.duration";
+
+ // /serverkeygen
+ internal const string ServerKeyGenRequests = "opencertserver.est.serverkeygen.requests";
+ internal const string ServerKeyGenSuccesses = "opencertserver.est.serverkeygen.successes";
+ internal const string ServerKeyGenFailures = "opencertserver.est.serverkeygen.failures";
+ internal const string ServerKeyGenDuration = "opencertserver.est.serverkeygen.duration";
+}
+
diff --git a/src/opencertserver.est.server/TagKeys.cs b/src/opencertserver.est.server/TagKeys.cs
new file mode 100644
index 0000000..54e86fc
--- /dev/null
+++ b/src/opencertserver.est.server/TagKeys.cs
@@ -0,0 +1,21 @@
+namespace OpenCertServer.Est.Server;
+
+///
+/// Centralised tag (attribute) key constants for all EST OpenTelemetry measurements.
+/// Use these keys when adding dimensions to counters, histograms and spans.
+///
+internal static class TagKeys
+{
+ /// EST profile name (empty string for the default profile).
+ internal const string Profile = "est.profile";
+
+ ///
+ /// Structured error type; set to the exception class name or a short error code
+ /// on all failure counter and span recordings.
+ ///
+ internal const string ErrorType = "error.type";
+
+ /// HTTP response status code recorded on non-2xx result paths.
+ internal const string HttpStatusCode = "http.response.status_code";
+}
+
diff --git a/src/opencertserver.tss.net/Globs.cs b/src/opencertserver.tss.net/Globs.cs
index b0a9830..20282ec 100644
--- a/src/opencertserver.tss.net/Globs.cs
+++ b/src/opencertserver.tss.net/Globs.cs
@@ -7,7 +7,6 @@
using System.Numerics;
using System.Reflection;
using System.Resources;
-using System.Runtime.InteropServices;
using System.Text;
namespace OpenCertServer.Tpm2Lib;
diff --git a/src/opencertserver.tss.net/Policy.cs b/src/opencertserver.tss.net/Policy.cs
index 23c6dc6..f33c30f 100644
--- a/src/opencertserver.tss.net/Policy.cs
+++ b/src/opencertserver.tss.net/Policy.cs
@@ -4,8 +4,6 @@
*/
using System.Runtime.Serialization;
-using System.Runtime.Serialization.Json;
-using System.Text;
namespace OpenCertServer.Tpm2Lib;
diff --git a/src/opencertserver.tss.net/Properties/AssemblyInfo.cs b/src/opencertserver.tss.net/Properties/AssemblyInfo.cs
index 2e66062..25fd91e 100644
--- a/src/opencertserver.tss.net/Properties/AssemblyInfo.cs
+++ b/src/opencertserver.tss.net/Properties/AssemblyInfo.cs
@@ -1,5 +1,4 @@
using System.Reflection;
-using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
diff --git a/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature b/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature
new file mode 100644
index 0000000..f1a2801
--- /dev/null
+++ b/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature
@@ -0,0 +1,31 @@
+Feature: OpenTelemetry Metrics
+
+ Background:
+ Given a certificate server
+ And an EST client
+ And an OpenTelemetry meter listener
+
+ Rule: EST metrics are emitted
+
+ Scenario: EST /cacerts endpoint increments request counter
+ When I fetch the CA certs over EST
+ Then the EST cacerts request counter should be greater than zero
+
+ Scenario: EST /simpleenroll endpoint increments request counter
+ When I enroll with a valid JWT
+ Then the EST simpleenroll request counter should be greater than zero
+
+ Rule: OCSP metrics are emitted
+
+ Scenario: OCSP request increments counter
+ When I enroll with a valid JWT
+ And I get a certificate
+ And I check the OCSP status of my certificate
+ Then the OCSP request counter should be greater than zero
+
+ Rule: CRL metrics are emitted
+
+ Scenario: CRL request increments counter
+ When I request the CRL
+ Then the CRL request counter should be greater than zero
+
diff --git a/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature.cs b/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature.cs
new file mode 100644
index 0000000..d78b727
--- /dev/null
+++ b/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature.cs
@@ -0,0 +1,311 @@
+// ------------------------------------------------------------------------------
+//
+// This code was generated by Reqnroll (https://reqnroll.net/).
+// Reqnroll Version:3.0.0.0
+// Reqnroll Generator Version:3.0.0.0
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+// ------------------------------------------------------------------------------
+#region Designer generated code
+#pragma warning disable
+using Reqnroll;
+namespace OpenCertServer.CertServer.Tests.Features
+{
+
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public partial class OpenTelemetryMetricsFeature : object, Xunit.IClassFixture, Xunit.IAsyncLifetime
+ {
+
+ private global::Reqnroll.ITestRunner testRunner;
+
+ private Xunit.ITestOutputHelper _testOutputHelper;
+
+ private static string[] featureTags = ((string[])(null));
+
+ private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "OpenTelemetry Metrics", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages());
+
+#line 1 "OpenTelemetryMetrics.feature"
+#line hidden
+
+ public OpenTelemetryMetricsFeature(OpenTelemetryMetricsFeature.FixtureData fixtureData, Xunit.ITestOutputHelper testOutputHelper)
+ {
+ this._testOutputHelper = testOutputHelper;
+ }
+
+ public static async global::System.Threading.Tasks.Task FeatureSetupAsync()
+ {
+ }
+
+ public static async global::System.Threading.Tasks.Task FeatureTearDownAsync()
+ {
+ await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo);
+ }
+
+ public async global::System.Threading.Tasks.Task TestInitializeAsync()
+ {
+ testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo);
+ try
+ {
+ if (((testRunner.FeatureContext != null)
+ && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false)))
+ {
+ await testRunner.OnFeatureEndAsync();
+ }
+ }
+ finally
+ {
+ if (((testRunner.FeatureContext != null)
+ && testRunner.FeatureContext.BeforeFeatureHookFailed))
+ {
+ throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error");
+ }
+ if ((testRunner.FeatureContext == null))
+ {
+ await testRunner.OnFeatureStartAsync(featureInfo);
+ }
+ }
+ }
+
+ public async global::System.Threading.Tasks.Task TestTearDownAsync()
+ {
+ if ((testRunner == null))
+ {
+ return;
+ }
+ try
+ {
+ await testRunner.OnScenarioEndAsync();
+ }
+ finally
+ {
+ global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner);
+ testRunner = null;
+ }
+ }
+
+ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo)
+ {
+ testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo);
+ testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testOutputHelper);
+ }
+
+ public async global::System.Threading.Tasks.Task ScenarioStartAsync()
+ {
+ await testRunner.OnScenarioStartAsync();
+ }
+
+ public async global::System.Threading.Tasks.Task ScenarioCleanupAsync()
+ {
+ await testRunner.CollectScenarioErrorsAsync();
+ }
+
+ public virtual async global::System.Threading.Tasks.Task FeatureBackgroundAsync()
+ {
+#line 3
+ #line hidden
+#line 4
+ await testRunner.GivenAsync("a certificate server", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given ");
+#line hidden
+#line 5
+ await testRunner.AndAsync("an EST client", ((string)(null)), ((global::Reqnroll.Table)(null)), "And ");
+#line hidden
+#line 6
+ await testRunner.AndAsync("an OpenTelemetry meter listener", ((string)(null)), ((global::Reqnroll.Table)(null)), "And ");
+#line hidden
+ }
+
+ private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages()
+ {
+ return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/OpenTelemetryMetrics.feature.ndjson", 6);
+ }
+
+ async System.Threading.Tasks.ValueTask Xunit.IAsyncLifetime.InitializeAsync()
+ {
+ try
+ {
+ await this.TestInitializeAsync();
+ }
+ catch (System.Exception e1)
+ {
+ try
+ {
+ ((Xunit.IAsyncLifetime)(this)).DisposeAsync();
+ }
+ catch (System.Exception e2)
+ {
+ throw new System.AggregateException("Test initialization failed", e1, e2);
+ }
+ throw;
+ }
+ }
+
+ async System.Threading.Tasks.ValueTask System.IAsyncDisposable.DisposeAsync()
+ {
+ await this.TestTearDownAsync();
+ }
+
+ [global::Xunit.FactAttribute(DisplayName="EST /cacerts endpoint increments request counter")]
+ [global::Xunit.TraitAttribute("FeatureTitle", "OpenTelemetry Metrics")]
+ [global::Xunit.TraitAttribute("Description", "EST /cacerts endpoint increments request counter")]
+ public async global::System.Threading.Tasks.Task ESTCacertsEndpointIncrementsRequestCounter()
+ {
+ string[] tagsOfScenario = ((string[])(null));
+ global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary();
+ string pickleIndex = "0";
+ global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("EST /cacerts endpoint increments request counter", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex);
+ string[] tagsOfRule = ((string[])(null));
+ global::Reqnroll.RuleInfo ruleInfo = new global::Reqnroll.RuleInfo("EST metrics are emitted", null, tagsOfRule);
+#line 10
+ this.ScenarioInitialize(scenarioInfo, ruleInfo);
+#line hidden
+ if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags)))
+ {
+ await testRunner.SkipScenarioAsync();
+ }
+ else
+ {
+ await this.ScenarioStartAsync();
+#line 3
+ await this.FeatureBackgroundAsync();
+#line hidden
+#line 11
+ await testRunner.WhenAsync("I fetch the CA certs over EST", ((string)(null)), ((global::Reqnroll.Table)(null)), "When ");
+#line hidden
+#line 12
+ await testRunner.ThenAsync("the EST cacerts request counter should be greater than zero", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then ");
+#line hidden
+ }
+ await this.ScenarioCleanupAsync();
+ }
+
+ [global::Xunit.FactAttribute(DisplayName="EST /simpleenroll endpoint increments request counter")]
+ [global::Xunit.TraitAttribute("FeatureTitle", "OpenTelemetry Metrics")]
+ [global::Xunit.TraitAttribute("Description", "EST /simpleenroll endpoint increments request counter")]
+ public async global::System.Threading.Tasks.Task ESTSimpleenrollEndpointIncrementsRequestCounter()
+ {
+ string[] tagsOfScenario = ((string[])(null));
+ global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary();
+ string pickleIndex = "1";
+ global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("EST /simpleenroll endpoint increments request counter", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex);
+ string[] tagsOfRule = ((string[])(null));
+ global::Reqnroll.RuleInfo ruleInfo = new global::Reqnroll.RuleInfo("EST metrics are emitted", null, tagsOfRule);
+#line 14
+ this.ScenarioInitialize(scenarioInfo, ruleInfo);
+#line hidden
+ if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags)))
+ {
+ await testRunner.SkipScenarioAsync();
+ }
+ else
+ {
+ await this.ScenarioStartAsync();
+#line 3
+ await this.FeatureBackgroundAsync();
+#line hidden
+#line 15
+ await testRunner.WhenAsync("I enroll with a valid JWT", ((string)(null)), ((global::Reqnroll.Table)(null)), "When ");
+#line hidden
+#line 16
+ await testRunner.ThenAsync("the EST simpleenroll request counter should be greater than zero", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then ");
+#line hidden
+ }
+ await this.ScenarioCleanupAsync();
+ }
+
+ [global::Xunit.FactAttribute(DisplayName="OCSP request increments counter")]
+ [global::Xunit.TraitAttribute("FeatureTitle", "OpenTelemetry Metrics")]
+ [global::Xunit.TraitAttribute("Description", "OCSP request increments counter")]
+ public async global::System.Threading.Tasks.Task OCSPRequestIncrementsCounter()
+ {
+ string[] tagsOfScenario = ((string[])(null));
+ global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary();
+ string pickleIndex = "2";
+ global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("OCSP request increments counter", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex);
+ string[] tagsOfRule = ((string[])(null));
+ global::Reqnroll.RuleInfo ruleInfo = new global::Reqnroll.RuleInfo("OCSP metrics are emitted", null, tagsOfRule);
+#line 20
+ this.ScenarioInitialize(scenarioInfo, ruleInfo);
+#line hidden
+ if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags)))
+ {
+ await testRunner.SkipScenarioAsync();
+ }
+ else
+ {
+ await this.ScenarioStartAsync();
+#line 3
+ await this.FeatureBackgroundAsync();
+#line hidden
+#line 21
+ await testRunner.WhenAsync("I enroll with a valid JWT", ((string)(null)), ((global::Reqnroll.Table)(null)), "When ");
+#line hidden
+#line 22
+ await testRunner.AndAsync("I get a certificate", ((string)(null)), ((global::Reqnroll.Table)(null)), "And ");
+#line hidden
+#line 23
+ await testRunner.AndAsync("I check the OCSP status of my certificate", ((string)(null)), ((global::Reqnroll.Table)(null)), "And ");
+#line hidden
+#line 24
+ await testRunner.ThenAsync("the OCSP request counter should be greater than zero", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then ");
+#line hidden
+ }
+ await this.ScenarioCleanupAsync();
+ }
+
+ [global::Xunit.FactAttribute(DisplayName="CRL request increments counter")]
+ [global::Xunit.TraitAttribute("FeatureTitle", "OpenTelemetry Metrics")]
+ [global::Xunit.TraitAttribute("Description", "CRL request increments counter")]
+ public async global::System.Threading.Tasks.Task CRLRequestIncrementsCounter()
+ {
+ string[] tagsOfScenario = ((string[])(null));
+ global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary();
+ string pickleIndex = "3";
+ global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("CRL request increments counter", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex);
+ string[] tagsOfRule = ((string[])(null));
+ global::Reqnroll.RuleInfo ruleInfo = new global::Reqnroll.RuleInfo("CRL metrics are emitted", null, tagsOfRule);
+#line 28
+ this.ScenarioInitialize(scenarioInfo, ruleInfo);
+#line hidden
+ if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags)))
+ {
+ await testRunner.SkipScenarioAsync();
+ }
+ else
+ {
+ await this.ScenarioStartAsync();
+#line 3
+ await this.FeatureBackgroundAsync();
+#line hidden
+#line 29
+ await testRunner.WhenAsync("I request the CRL", ((string)(null)), ((global::Reqnroll.Table)(null)), "When ");
+#line hidden
+#line 30
+ await testRunner.ThenAsync("the CRL request counter should be greater than zero", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then ");
+#line hidden
+ }
+ await this.ScenarioCleanupAsync();
+ }
+
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public class FixtureData : object, Xunit.IAsyncLifetime
+ {
+
+ async System.Threading.Tasks.ValueTask Xunit.IAsyncLifetime.InitializeAsync()
+ {
+ await OpenTelemetryMetricsFeature.FeatureSetupAsync();
+ }
+
+ async System.Threading.Tasks.ValueTask System.IAsyncDisposable.DisposeAsync()
+ {
+ await OpenTelemetryMetricsFeature.FeatureTearDownAsync();
+ }
+ }
+ }
+}
+#pragma warning restore
+#endregion
diff --git a/tests/opencertserver.certserver.tests/StepDefinitions/EstConformance.cs b/tests/opencertserver.certserver.tests/StepDefinitions/EstConformance.cs
index f40f72c..b792be0 100644
--- a/tests/opencertserver.certserver.tests/StepDefinitions/EstConformance.cs
+++ b/tests/opencertserver.certserver.tests/StepDefinitions/EstConformance.cs
@@ -1,6 +1,5 @@
using System.Formats.Asn1;
using OpenCertServer.Ca.Utils.Ca;
-using OpenCertServer.Est.Server.Handlers;
using OpenCertServer.Est.Server.Response;
namespace OpenCertServer.CertServer.Tests.StepDefinitions;
diff --git a/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs b/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs
new file mode 100644
index 0000000..51c8a14
--- /dev/null
+++ b/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs
@@ -0,0 +1,121 @@
+namespace OpenCertServer.CertServer.Tests.StepDefinitions;
+
+using System.Diagnostics.Metrics;
+using System.Security.Cryptography;
+using OpenCertServer.Ca.Utils.Ocsp;
+using Reqnroll;
+using Xunit;
+
+public partial class CertificateServerFeatures
+{
+ private MeterListener? _meterListener;
+ private readonly Dictionary _metricCounters = new();
+
+ [Given("an OpenTelemetry meter listener")]
+ public void GivenAnOpenTelemetryMeterListener()
+ {
+ _meterListener = new MeterListener();
+ _meterListener.InstrumentPublished = (instrument, listener) =>
+ {
+ if (instrument.Meter.Name.StartsWith("opencertserver.", StringComparison.OrdinalIgnoreCase))
+ {
+ listener.EnableMeasurementEvents(instrument);
+ }
+ };
+ _meterListener.SetMeasurementEventCallback((instrument, measurement, _, _) =>
+ {
+ lock (_metricCounters)
+ {
+ _metricCounters.TryGetValue(instrument.Name, out var existing);
+ _metricCounters[instrument.Name] = existing + measurement;
+ }
+ });
+ _meterListener.Start();
+ }
+
+ [When("I fetch the CA certs over EST")]
+ public async Task WhenIFetchTheCaCertsOverEst()
+ {
+ using var client = _server.CreateClient();
+ var response = await client.GetAsync("/.well-known/est/rsa/cacerts").ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ }
+
+ [Then("the EST cacerts request counter should be greater than zero")]
+ public void ThenTheEstCacertsRequestCounterShouldBeGreaterThanZero()
+ {
+ _meterListener?.RecordObservableInstruments();
+ lock (_metricCounters)
+ {
+ Assert.True(
+ _metricCounters.TryGetValue("opencertserver.est.cacerts.requests", out var count) && count > 0,
+ $"Expected opencertserver.est.cacerts.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.est.cacerts.requests", out var c) ? c : 0)}");
+ }
+ }
+
+ [Then("the EST simpleenroll request counter should be greater than zero")]
+ public void ThenTheEstSimpleenrollRequestCounterShouldBeGreaterThanZero()
+ {
+ _meterListener?.RecordObservableInstruments();
+ lock (_metricCounters)
+ {
+ Assert.True(
+ _metricCounters.TryGetValue("opencertserver.est.simpleenroll.requests", out var count) && count > 0,
+ $"Expected opencertserver.est.simpleenroll.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.est.simpleenroll.requests", out var c) ? c : 0)}");
+ }
+ }
+
+ [When("I check the OCSP status of my certificate")]
+ public async Task WhenICheckTheOcspStatusOfMyCertificate()
+ {
+ var issuerCert = await GetIssuerCertAsync().ConfigureAwait(false);
+ var tbsRequest = new TbsRequest(requestList:
+ [
+ new Request(CertId.Create(_certCollection[0], issuerCert, HashAlgorithmName.SHA256))
+ ]);
+ var ocspRequest = new OcspRequest(tbsRequest);
+ var ocspResponse = await GetOcspResponse(ocspRequest).ConfigureAwait(false);
+ _scenarioContext["ocspResponse"] = ocspResponse;
+ }
+
+ [Then("the OCSP request counter should be greater than zero")]
+ public void ThenTheOcspRequestCounterShouldBeGreaterThanZero()
+ {
+ _meterListener?.RecordObservableInstruments();
+ lock (_metricCounters)
+ {
+ Assert.True(
+ _metricCounters.TryGetValue("opencertserver.ocsp.request.requests", out var count) && count > 0,
+ $"Expected opencertserver.ocsp.request.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.ocsp.request.requests", out var c) ? c : 0)}");
+ }
+ }
+
+ [When("I request the CRL")]
+ public async Task WhenIRequestTheCrl()
+ {
+ using var client = _server.CreateClient();
+ var response = await client.GetAsync("/ca/rsa/crl").ConfigureAwait(false);
+ // CRL endpoint may return 200 or different codes; we capture it for the assertion
+ _scenarioContext["crlStatusCode"] = (int)response.StatusCode;
+ }
+
+ [Then("the CRL request counter should be greater than zero")]
+ public void ThenTheCrlRequestCounterShouldBeGreaterThanZero()
+ {
+ _meterListener?.RecordObservableInstruments();
+ lock (_metricCounters)
+ {
+ Assert.True(
+ _metricCounters.TryGetValue("opencertserver.crl.request.requests", out var count) && count > 0,
+ $"Expected opencertserver.crl.request.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.crl.request.requests", out var c) ? c : 0)}");
+ }
+ }
+
+ [AfterScenario]
+ public void DisposeMeterListener()
+ {
+ _meterListener?.Dispose();
+ _meterListener = null;
+ }
+}
+
diff --git a/tests/opencertserver.certserver.tests/Support/OpenTelemetryCollectorContainer.cs b/tests/opencertserver.certserver.tests/Support/OpenTelemetryCollectorContainer.cs
new file mode 100644
index 0000000..0743de4
--- /dev/null
+++ b/tests/opencertserver.certserver.tests/Support/OpenTelemetryCollectorContainer.cs
@@ -0,0 +1,141 @@
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+
+namespace OpenCertServer.CertServer.Tests.Support;
+
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+///
+/// Hosts an OpenTelemetry Collector container for acceptance tests.
+///
+internal sealed class OpenTelemetryCollectorContainer : IAsyncDisposable
+{
+ private const ushort OtlpGrpcPort = 4317;
+ private const ushort OtlpHttpPort = 4318;
+ private const ushort PrometheusPort = 9464;
+ private readonly IContainer _container;
+ private readonly HttpClient _httpClient = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public OpenTelemetryCollectorContainer()
+ {
+ var configPath = ResolveConfigPath();
+ _container = new ContainerBuilder("otel/opentelemetry-collector-contrib:latest")
+ .WithName($"dotauth-otel-{Guid.NewGuid():N}")
+ .WithPortBinding(OtlpGrpcPort, true)
+ .WithPortBinding(OtlpHttpPort, true)
+ .WithPortBinding(PrometheusPort, true)
+ .WithResourceMapping(new FileInfo(configPath), "/etc/otelcol-contrib/")
+ .WithCommand("--config=/etc/otelcol-contrib/otel-collector-config.yaml")
+ .WithWaitStrategy(
+ Wait.ForUnixContainer()
+ .UntilMessageIsLogged("Everything is ready"))
+ .Build();
+ }
+
+ ///
+ /// Gets the OTLP gRPC endpoint that the server should export telemetry to.
+ ///
+ public Uri OtlpGrpcEndpoint => new($"http://localhost:{_container.GetMappedPublicPort(OtlpGrpcPort)}");
+
+ ///
+ /// Gets the Prometheus scrape endpoint exposed by the collector.
+ ///
+ public Uri PrometheusEndpoint => new($"http://localhost:{_container.GetMappedPublicPort(PrometheusPort)}/metrics");
+
+ ///
+ /// Gets the OTLP HTTP endpoint that the server can export telemetry to.
+ ///
+ public Uri OtlpHttpEndpoint => new($"http://localhost:{_container.GetMappedPublicPort(OtlpHttpPort)}");
+
+ ///
+ /// Starts the collector container.
+ ///
+ /// The cancellation token.
+ public async Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
+ await _container.StartAsync(timeoutCts.Token).ConfigureAwait(false);
+ }
+
+ ///
+ /// Reads the collector logs, which include exported traces via the debug exporter.
+ ///
+ /// The cancellation token.
+ public Task ReadTracesAsync(CancellationToken cancellationToken = default)
+ {
+ return ReadLogsAsync(cancellationToken);
+ }
+
+ ///
+ /// Reads the collector logs, which include exported metrics via the debug exporter.
+ ///
+ /// The cancellation token.
+ public Task ReadMetricsAsync(CancellationToken cancellationToken = default)
+ {
+ return ReadLogsAsync(cancellationToken);
+ }
+
+ ///
+ /// Reads the Prometheus metrics endpoint from the collector.
+ ///
+ /// The cancellation token.
+ public Task ReadPrometheusMetricsAsync(CancellationToken cancellationToken = default)
+ {
+ return _httpClient.GetStringAsync(PrometheusEndpoint, cancellationToken);
+ }
+
+ ///
+ /// Stops and disposes the collector container resources.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await _container.DisposeAsync().ConfigureAwait(false);
+ _httpClient.Dispose();
+ }
+
+ ///
+ /// Reads the collector logs from the container.
+ ///
+ /// The cancellation token.
+ /// The combined standard output and error logs.
+ private async Task ReadLogsAsync(CancellationToken cancellationToken)
+ {
+ var now = DateTime.UtcNow;
+ var result = await _container
+ .GetLogsAsync(DateTime.UnixEpoch, now, false, cancellationToken)
+ .ConfigureAwait(false);
+
+ return string.Concat(result.Stdout, Environment.NewLine, result.Stderr);
+ }
+
+ ///
+ /// Resolves the collector configuration file from either the test output directory or the project support folder.
+ ///
+ /// The absolute config file path.
+ /// Thrown when the collector config cannot be located.
+ private static string ResolveConfigPath()
+ {
+ var outputPath = Path.Combine(AppContext.BaseDirectory, "otel-collector-config.yaml");
+ if (File.Exists(outputPath))
+ {
+ return outputPath;
+ }
+
+ var projectPath = Path.GetFullPath(
+ Path.Combine(AppContext.BaseDirectory, "../../../Support/otel-collector-config.yaml"));
+ if (File.Exists(projectPath))
+ {
+ return projectPath;
+ }
+
+ throw new FileNotFoundException("The OpenTelemetry collector config file could not be found.", outputPath);
+ }
+}
diff --git a/tests/opencertserver.certserver.tests/Support/otel-collector-config.yaml b/tests/opencertserver.certserver.tests/Support/otel-collector-config.yaml
new file mode 100644
index 0000000..1b6cce6
--- /dev/null
+++ b/tests/opencertserver.certserver.tests/Support/otel-collector-config.yaml
@@ -0,0 +1,30 @@
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+ http:
+ endpoint: 0.0.0.0:4318
+
+processors:
+ batch:
+ timeout: 1s
+ send_batch_size: 1024
+
+exporters:
+ debug:
+ verbosity: detailed
+ prometheus:
+ endpoint: "0.0.0.0:9464"
+
+service:
+ pipelines:
+ metrics:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [debug, prometheus]
+ traces:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [debug]
+
diff --git a/tests/opencertserver.certserver.tests/opencertserver.certserver.tests.csproj b/tests/opencertserver.certserver.tests/opencertserver.certserver.tests.csproj
index 2f00a15..50d2a0d 100644
--- a/tests/opencertserver.certserver.tests/opencertserver.certserver.tests.csproj
+++ b/tests/opencertserver.certserver.tests/opencertserver.certserver.tests.csproj
@@ -10,6 +10,7 @@
+
all