diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml
index 2d38587..3fc0b80 100644
--- a/.github/actions/deploy/action.yml
+++ b/.github/actions/deploy/action.yml
@@ -55,7 +55,6 @@ runs:
LASTFM_API_KEY: op://ci-cd/lho-lambda/LASTFM_API_KEY
LASTFM_USERNAME: op://ci-cd/lho-lambda/LASTFM_USERNAME
SENTRY_DSN: op://ci-cd/lho-lambda/SENTRY_DSN
- PUSHGATEWAY_AUTH_HEADER: op://ci-cd/lho-lambda/PUSHGATEWAY_AUTH_HEADER
OP_ENV_FILE: ".env"
- name: Set Terraform variables
@@ -67,7 +66,6 @@ runs:
echo "TF_VAR_lastfm_api_key=$LASTFM_API_KEY" >> $GITHUB_ENV
echo "TF_VAR_lastfm_username=$LASTFM_USERNAME" >> $GITHUB_ENV
echo "TF_VAR_sentry_dsn=$SENTRY_DSN" >> $GITHUB_ENV
- echo "TF_VAR_pushgateway_auth_header=$PUSHGATEWAY_AUTH_HEADER" >> $GITHUB_ENV
- name: Terraform init
shell: bash
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f4ae49b..14421d9 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -43,6 +43,8 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
+ # PAT with contents write; GITHUB_TOKEN cannot push to main under the main_protect ruleset
+ token: ${{ secrets.RELEASE_TOKEN }}
- name: Fetch latest commits
run: git stash && git fetch && git pull
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fe46e38..aa8e8f7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -39,6 +39,8 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
+ # PAT with contents write; GITHUB_TOKEN cannot push to main under the main_protect ruleset
+ token: ${{ secrets.RELEASE_TOKEN }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
diff --git a/CONTEXT.md b/CONTEXT.md
index 3201dfa..12f533d 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -30,8 +30,22 @@ Runtime Configuration is the environment-backed settings required by the Lambda
Runtime Configuration should be one shared module used by the API Lambda, authorizer, and observability code. Observability should not maintain a separate environment reader.
-Runtime Configuration should validate lazily by section. A missing provider secret should fail only the path that needs that provider. Missing Sentry or Pushgateway settings should disable that sink rather than fail requests. Missing authorizer API key should continue to deny authorization instead of throwing.
+Runtime Configuration should validate lazily by section. A missing provider secret should fail only the path that needs that provider. Missing Sentry settings should disable that sink rather than fail requests. Missing authorizer API key should continue to deny authorization instead of throwing.
Runtime Configuration should expose typed section objects instead of raw environment-variable lookups. Sections should own their defaults, optional sink behaviour, and provider-specific credential rules.
Runtime Configuration should use typed `*Options` classes with `Section` constants, init properties/defaults, and targeted `IsValid(out errorMessage)` validation where a section has required values.
+
+### Lambda Invocation
+
+A Lambda Invocation is the observability envelope around one handler execution. Beginning an invocation starts the Sentry transaction and the duration timer; completing it finishes the transaction and emits the structured request log and Sentry invocation metrics in one place.
+
+Handlers should not talk to individual observability sinks per request. They begin an invocation, record failures on it, set late-bound tags (provider, reason) as they become known, and complete it with a status code. Completion must fire even when the handler re-throws.
+
+All sinks should share one canonical snake_case tag vocabulary (`request_id`, `operation`, `route`, `method`, `status_code`, `duration_ms`, `outcome`, `consumer`, `provider`, `reason`). A tag key should never be spelled differently in logs, metrics, and Sentry.
+
+### Consumer
+
+A Consumer is the calling application identified by the `x-consumer` header. The valid consumers are `lhowsam-dev`, `lhowsam-prod`, and `lhowsam-local`.
+
+Consumer identity belongs to Runtime Configuration and should be one shared module. Normalisation maps a valid value to itself, a missing header to no consumer, and any other value to `unknown`. The authorizer denies unknown consumers; observability tags requests with the normalised consumer.
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index ed54823..0c894a1 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,5 +3,7 @@
enable
enable
false
+
+ true
diff --git a/src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs b/src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs
index 0796940..58b8f46 100644
--- a/src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs
+++ b/src/Lho.Lambda.Authorizer/Functions/AuthorizerFunction.cs
@@ -1,4 +1,3 @@
-using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Amazon.Lambda.Core;
@@ -11,40 +10,32 @@ namespace Lho.Lambda.Authorizer.Functions;
public class AuthorizerFunction
{
- private static readonly HashSet ValidConsumers = ["lhowsam-dev", "lhowsam-prod", "lhowsam-local"];
-
private readonly AuthorizerOptions _authorizerOptions;
+ private readonly ObservabilityOptions _observabilityOptions;
public AuthorizerFunction()
- : this(RuntimeConfig.Current.Authorizer)
+ : this(RuntimeConfig.Current.Authorizer, RuntimeConfig.Current.Observability)
{
}
- public AuthorizerFunction(AuthorizerOptions authorizerOptions)
+ public AuthorizerFunction(AuthorizerOptions authorizerOptions, ObservabilityOptions observabilityOptions)
{
_authorizerOptions = authorizerOptions;
+ _observabilityOptions = observabilityOptions;
}
public async Task FunctionHandler(AuthorizerRequest request, ILambdaContext context)
{
- var stopwatch = Stopwatch.StartNew();
- var consumer = NormaliseConsumer(GetHeaderValue(request.Headers, "x-consumer"));
+ var consumer = Consumers.Normalise(GetHeaderValue(request.Headers, "x-consumer"));
var route = request.RouteKey ?? request.RequestContext?.Http?.Path ?? "authorizer";
var method = request.RequestContext?.Http?.Method ?? "AUTH";
- var reason = "allowed";
var isAuthorized = false;
- Exception? capturedException = null;
- SentryTelemetry.Initialise(context.Logger);
- using var transaction = SentryTelemetry.StartTransaction(context.Logger, $"{method} {route}", "http.server", new Dictionary
- {
- ["function"] = context.FunctionName,
- ["request_id"] = context.AwsRequestId,
- ["operation"] = "authorizer",
- ["route"] = route,
- ["method"] = method,
- ["consumer"] = consumer
- });
+
+ await using var invocation = LambdaInvocation.Begin(
+ context,
+ new InvocationDescriptor("authorizer", route, method, consumer, LogEventPrefix: "authorizer.request"),
+ _observabilityOptions);
try
{
@@ -53,72 +44,31 @@ public async Task FunctionHandler(AuthorizerRequest re
if (!SecureCompare(apiKey, validKey))
{
- reason = "invalid_api_key";
+ invocation.SetReason("invalid_api_key");
return new AuthorizerSimpleResponse(false);
}
- if (consumer is not null && !ValidConsumers.Contains(consumer))
+ if (consumer is not null && !Consumers.IsValid(consumer))
{
- reason = "invalid_consumer";
+ invocation.SetReason("invalid_consumer");
return new AuthorizerSimpleResponse(false);
}
isAuthorized = true;
+ invocation.SetReason("allowed");
return new AuthorizerSimpleResponse(true);
}
catch (Exception exception)
{
- capturedException = exception;
- reason = "exception";
- StructuredLog.Error(context.Logger, "authorizer.error", exception, new Dictionary
- {
- ["requestId"] = context.AwsRequestId,
- ["function"] = context.FunctionName,
- ["route"] = route,
- ["method"] = method,
- ["consumer"] = consumer
- });
- await SentryTelemetry.CaptureExceptionAsync(exception, context.Logger, new Dictionary
- {
- ["function"] = context.FunctionName,
- ["request_id"] = context.AwsRequestId,
- ["operation"] = "authorizer",
- ["route"] = route,
- ["consumer"] = consumer
- });
+ invocation.SetReason("exception");
+ await invocation.RecordFailureAsync(exception);
throw;
}
finally
{
- stopwatch.Stop();
- var statusCode = isAuthorized ? 200 : 401;
- var outcome = isAuthorized ? "success" : "denied";
- StructuredLog.Info(context.Logger, "authorizer.request", new Dictionary
- {
- ["requestId"] = context.AwsRequestId,
- ["function"] = context.FunctionName,
- ["route"] = route,
- ["method"] = method,
- ["statusCode"] = statusCode,
- ["durationMs"] = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2),
- ["outcome"] = outcome,
- ["reason"] = reason,
- ["consumer"] = consumer
- });
-
- var metric = new InvocationMetric(
- FunctionName: context.FunctionName,
- Operation: "authorizer",
- Route: route,
- Method: method,
- StatusCode: statusCode,
- DurationMs: stopwatch.Elapsed.TotalMilliseconds,
- Outcome: outcome,
- Consumer: consumer);
-
- transaction?.Finish(statusCode, capturedException);
- await PrometheusMetrics.PushInvocationAsync(metric, context.Logger);
- await SentryTelemetry.RecordInvocationAsync(metric, context.Logger);
+ await invocation.CompleteAsync(
+ isAuthorized ? 200 : 401,
+ isAuthorized ? "success" : "denied");
}
}
@@ -153,14 +103,4 @@ private static bool SecureCompare(string? first, string? second)
return firstBytes.Length == secondBytes.Length &&
CryptographicOperations.FixedTimeEquals(firstBytes, secondBytes);
}
-
- private static string? NormaliseConsumer(string? consumer)
- {
- return consumer switch
- {
- "lhowsam-prod" or "lhowsam-dev" or "lhowsam-local" => consumer,
- null or "" => null,
- _ => "unknown"
- };
- }
}
diff --git a/src/Lho.Lambda.Authorizer/Properties/AssemblyInfo.cs b/src/Lho.Lambda.Authorizer/Properties/AssemblyInfo.cs
index ebee51c..1dd99e4 100644
--- a/src/Lho.Lambda.Authorizer/Properties/AssemblyInfo.cs
+++ b/src/Lho.Lambda.Authorizer/Properties/AssemblyInfo.cs
@@ -1,3 +1,5 @@
using Amazon.Lambda.Core;
+using Amazon.Lambda.Serialization.SystemTextJson;
+using Lho.Lambda.Authorizer.Serialization;
-[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
+[assembly: LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer))]
diff --git a/src/Lho.Lambda.Authorizer/Serialization/LambdaEventsJsonContext.cs b/src/Lho.Lambda.Authorizer/Serialization/LambdaEventsJsonContext.cs
new file mode 100644
index 0000000..036fd59
--- /dev/null
+++ b/src/Lho.Lambda.Authorizer/Serialization/LambdaEventsJsonContext.cs
@@ -0,0 +1,8 @@
+using System.Text.Json.Serialization;
+using Lho.Lambda.Authorizer.Models;
+
+namespace Lho.Lambda.Authorizer.Serialization;
+
+[JsonSerializable(typeof(AuthorizerRequest))]
+[JsonSerializable(typeof(AuthorizerSimpleResponse))]
+public partial class LambdaEventsJsonContext : JsonSerializerContext;
diff --git a/src/Lho.Lambda.Observability/InvocationMetric.cs b/src/Lho.Lambda.Observability/InvocationMetric.cs
new file mode 100644
index 0000000..cfd944a
--- /dev/null
+++ b/src/Lho.Lambda.Observability/InvocationMetric.cs
@@ -0,0 +1,12 @@
+namespace Lho.Lambda.Observability;
+
+public sealed record InvocationMetric(
+ string FunctionName,
+ string Operation,
+ string Route,
+ string Method,
+ int StatusCode,
+ double DurationMs,
+ string Outcome,
+ string? Consumer = null,
+ string? Provider = null);
diff --git a/src/Lho.Lambda.Observability/InvocationTags.cs b/src/Lho.Lambda.Observability/InvocationTags.cs
new file mode 100644
index 0000000..611d594
--- /dev/null
+++ b/src/Lho.Lambda.Observability/InvocationTags.cs
@@ -0,0 +1,19 @@
+namespace Lho.Lambda.Observability;
+
+public static class InvocationTags
+{
+ public const string Function = "function";
+ public const string RequestId = "request_id";
+ public const string Operation = "operation";
+ public const string Route = "route";
+ public const string Method = "method";
+ public const string Consumer = "consumer";
+ public const string Provider = "provider";
+ public const string Reason = "reason";
+ public const string StatusCode = "status_code";
+ public const string DurationMs = "duration_ms";
+ public const string Outcome = "outcome";
+ public const string TimeRange = "time_range";
+ public const string ErrorType = "error_type";
+ public const string ErrorMessage = "error_message";
+}
diff --git a/src/Lho.Lambda.Observability/LambdaInvocation.cs b/src/Lho.Lambda.Observability/LambdaInvocation.cs
new file mode 100644
index 0000000..9696ea0
--- /dev/null
+++ b/src/Lho.Lambda.Observability/LambdaInvocation.cs
@@ -0,0 +1,142 @@
+using System.Diagnostics;
+using Amazon.Lambda.Core;
+using Lho.Lambda.RuntimeConfiguration.Options;
+
+namespace Lho.Lambda.Observability;
+
+public sealed record InvocationDescriptor(
+ string Operation,
+ string Route,
+ string Method,
+ string? Consumer = null,
+ string LogEventPrefix = "api.request");
+
+public sealed class LambdaInvocation : IAsyncDisposable
+{
+ private readonly ILambdaContext _context;
+ private readonly InvocationDescriptor _descriptor;
+ private readonly ObservabilityOptions _options;
+ private readonly Stopwatch _stopwatch;
+ private readonly Dictionary _tags;
+ private readonly SentryTelemetry.SentryTransactionScope? _transaction;
+ private Exception? _failure;
+ private bool _completed;
+
+ private LambdaInvocation(
+ ILambdaContext context,
+ InvocationDescriptor descriptor,
+ ObservabilityOptions options,
+ Dictionary tags,
+ SentryTelemetry.SentryTransactionScope? transaction)
+ {
+ _context = context;
+ _descriptor = descriptor;
+ _options = options;
+ _tags = tags;
+ _transaction = transaction;
+ _stopwatch = Stopwatch.StartNew();
+ }
+
+ public static LambdaInvocation Begin(ILambdaContext context, InvocationDescriptor descriptor, ObservabilityOptions options)
+ {
+ var tags = new Dictionary
+ {
+ [InvocationTags.Function] = context.FunctionName,
+ [InvocationTags.RequestId] = context.AwsRequestId,
+ [InvocationTags.Operation] = descriptor.Operation,
+ [InvocationTags.Route] = descriptor.Route,
+ [InvocationTags.Method] = descriptor.Method,
+ [InvocationTags.Consumer] = descriptor.Consumer
+ };
+
+ var transaction = SentryTelemetry.StartTransaction(
+ options,
+ context.Logger,
+ $"{descriptor.Method} {descriptor.Route}",
+ "http.server",
+ tags);
+
+ return new LambdaInvocation(context, descriptor, options, tags, transaction);
+ }
+
+ public void SetTag(string key, string? value)
+ {
+ _tags[key] = value;
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ _transaction?.SetTag(key, value);
+ }
+ }
+
+ public void SetProvider(string provider)
+ {
+ SetTag(InvocationTags.Provider, provider);
+ }
+
+ public void SetReason(string reason)
+ {
+ SetTag(InvocationTags.Reason, reason);
+ }
+
+ public async Task RecordFailureAsync(Exception exception)
+ {
+ _failure = exception;
+ StructuredLog.Error(_context.Logger, $"{_descriptor.LogEventPrefix}.error", exception, _options, LogFields());
+ await SentryTelemetry.CaptureExceptionAsync(exception, _options, _context.Logger, _tags);
+ }
+
+ public async Task CompleteAsync(int statusCode, string? outcome = null)
+ {
+ if (_completed)
+ {
+ return;
+ }
+
+ _completed = true;
+ _stopwatch.Stop();
+ outcome ??= statusCode >= 500 ? "error" : statusCode >= 400 ? "client_error" : "success";
+
+ _transaction?.Finish(statusCode, _failure);
+
+ var fields = LogFields();
+ fields[InvocationTags.StatusCode] = statusCode;
+ fields[InvocationTags.DurationMs] = Math.Round(_stopwatch.Elapsed.TotalMilliseconds, 2);
+ fields[InvocationTags.Outcome] = outcome;
+ StructuredLog.Info(_context.Logger, _descriptor.LogEventPrefix, _options, fields);
+
+ var metric = new InvocationMetric(
+ FunctionName: _context.FunctionName,
+ Operation: _descriptor.Operation,
+ Route: _descriptor.Route,
+ Method: _descriptor.Method,
+ StatusCode: statusCode,
+ DurationMs: _stopwatch.Elapsed.TotalMilliseconds,
+ Outcome: outcome,
+ Consumer: _descriptor.Consumer,
+ Provider: _tags.GetValueOrDefault(InvocationTags.Provider));
+
+ await SentryTelemetry.RecordInvocationAsync(metric, _options, _context.Logger);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (!_completed)
+ {
+ await CompleteAsync(_failure is null ? 200 : 500);
+ }
+ }
+
+ private Dictionary LogFields()
+ {
+ var fields = new Dictionary();
+ foreach (var (key, value) in _tags)
+ {
+ if (value is not null)
+ {
+ fields[key] = value;
+ }
+ }
+
+ return fields;
+ }
+}
diff --git a/src/Lho.Lambda.Observability/ObservabilityConfig.cs b/src/Lho.Lambda.Observability/ObservabilityConfig.cs
deleted file mode 100644
index 664b192..0000000
--- a/src/Lho.Lambda.Observability/ObservabilityConfig.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using Lho.Lambda.RuntimeConfiguration;
-using Lho.Lambda.RuntimeConfiguration.Options;
-
-namespace Lho.Lambda.Observability;
-
-public static class ObservabilityConfig
-{
- private static ObservabilityOptions Config => RuntimeConfig.Current.Observability;
-
- public static string ServiceName => Config.ServiceName;
-
- public static string EnvironmentName => Config.EnvironmentName;
-
- public static string Version => Config.Version;
-
- public static string GitSha => Config.GitSha;
-
- public static string SentryEnvironment => Config.SentryEnvironment;
-
- public static string SentryRelease => Config.SentryRelease;
-
- public static string? SentryDsn => Config.SentryDsn;
-
- public static string? PushgatewayUrl => Config.PushgatewayUrl;
-
- public static string? PushgatewayAuthHeader => Config.PushgatewayAuthHeader;
-
- public static string PushgatewayJob => Config.PushgatewayJob;
-
- public static bool MetricsEnabled => Config.MetricsEnabled;
-
- public static double SentryTracesSampleRate => Config.SentryTracesSampleRate;
-}
diff --git a/src/Lho.Lambda.Observability/PrometheusMetrics.cs b/src/Lho.Lambda.Observability/PrometheusMetrics.cs
deleted file mode 100644
index 4d10e76..0000000
--- a/src/Lho.Lambda.Observability/PrometheusMetrics.cs
+++ /dev/null
@@ -1,149 +0,0 @@
-using System.Collections.Concurrent;
-using System.Globalization;
-using System.Net.Http.Headers;
-using System.Text;
-using Amazon.Lambda.Core;
-
-namespace Lho.Lambda.Observability;
-
-public static class PrometheusMetrics
-{
- private static readonly HttpClient HttpClient = new()
- {
- Timeout = TimeSpan.FromSeconds(2)
- };
- private static readonly ConcurrentDictionary Counters = new();
-
- public static async Task PushInvocationAsync(InvocationMetric metric, ILambdaLogger logger)
- {
- if (!ObservabilityConfig.MetricsEnabled)
- {
- return;
- }
-
- try
- {
- var counterKey = $"{metric.FunctionName}|{metric.Operation}|{metric.Route}|{metric.Method}|{metric.StatusCode}|{metric.Outcome}|{metric.Consumer}|{metric.Provider}";
- var count = Counters.AddOrUpdate(counterKey, 1, (_, current) => current + 1);
- var body = BuildBody(metric, count);
- var endpoint = BuildPushgatewayEndpoint(metric.FunctionName);
-
- using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
- request.Content = new StringContent(body, Encoding.UTF8);
- request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain")
- {
- CharSet = "utf-8"
- };
- AddConfiguredAuthHeader(request);
-
- using var response = await HttpClient.SendAsync(request);
- if (!response.IsSuccessStatusCode)
- {
- logger.LogLine($"Pushgateway rejected metrics with status {(int)response.StatusCode}");
- }
- }
- catch (Exception exception)
- {
- logger.LogLine($"Failed to push Prometheus metrics: {exception.Message}");
- }
- }
-
- private static string BuildBody(InvocationMetric metric, long count)
- {
- var labels = Labels(metric);
- var durationSeconds = metric.DurationMs / 1000d;
- var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
-
- return string.Join('\n', [
- "# TYPE lho_lambda_invocations_total counter",
- $"lho_lambda_invocations_total{{{labels}}} {count.ToString(CultureInfo.InvariantCulture)}",
- "# TYPE lho_lambda_invocation_duration_seconds gauge",
- $"lho_lambda_invocation_duration_seconds{{{labels}}} {durationSeconds.ToString("0.###", CultureInfo.InvariantCulture)}",
- "# TYPE lho_lambda_last_invocation_timestamp_seconds gauge",
- $"lho_lambda_last_invocation_timestamp_seconds{{{labels}}} {timestamp.ToString(CultureInfo.InvariantCulture)}",
- ""
- ]);
- }
-
- private static string Labels(InvocationMetric metric)
- {
- var labels = new Dictionary
- {
- ["service"] = ObservabilityConfig.ServiceName,
- ["environment"] = ObservabilityConfig.EnvironmentName,
- ["version"] = ObservabilityConfig.Version,
- ["git_sha"] = ObservabilityConfig.GitSha,
- ["function"] = metric.FunctionName,
- ["operation"] = metric.Operation,
- ["route"] = metric.Route,
- ["method"] = metric.Method,
- ["status"] = metric.StatusCode.ToString(CultureInfo.InvariantCulture),
- ["outcome"] = metric.Outcome
- };
-
- if (!string.IsNullOrWhiteSpace(metric.Consumer))
- {
- labels["consumer"] = metric.Consumer;
- }
-
- if (!string.IsNullOrWhiteSpace(metric.Provider))
- {
- labels["provider"] = metric.Provider;
- }
-
- return string.Join(",", labels.Select(label => $"{label.Key}=\"{EscapeLabelValue(label.Value)}\""));
- }
-
- private static Uri BuildPushgatewayEndpoint(string functionName)
- {
- var baseUri = ObservabilityConfig.PushgatewayUrl!.TrimEnd('/');
- var job = Uri.EscapeDataString(ObservabilityConfig.PushgatewayJob);
- var environment = Uri.EscapeDataString(ObservabilityConfig.EnvironmentName);
- var function = Uri.EscapeDataString(functionName);
-
- return new Uri($"{baseUri}/metrics/job/{job}/environment/{environment}/function/{function}");
- }
-
- private static void AddConfiguredAuthHeader(HttpRequestMessage request)
- {
- var authHeader = ObservabilityConfig.PushgatewayAuthHeader;
- if (string.IsNullOrWhiteSpace(authHeader))
- {
- return;
- }
-
- var separator = authHeader.IndexOf('=', StringComparison.Ordinal);
- if (separator <= 0 || separator == authHeader.Length - 1)
- {
- return;
- }
-
- var name = authHeader[..separator].Trim();
- var value = authHeader[(separator + 1)..].Trim();
- if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
- {
- return;
- }
-
- request.Headers.TryAddWithoutValidation(name, value);
- }
-
- private static string EscapeLabelValue(string value)
- {
- return value
- .Replace("\\", "\\\\", StringComparison.Ordinal)
- .Replace("\n", "\\n", StringComparison.Ordinal)
- .Replace("\"", "\\\"", StringComparison.Ordinal);
- }
-}
-
-public sealed record InvocationMetric(
- string FunctionName,
- string Operation,
- string Route,
- string Method,
- int StatusCode,
- double DurationMs,
- string Outcome,
- string? Consumer = null,
- string? Provider = null);
diff --git a/src/Lho.Lambda.Observability/SentryTelemetry.cs b/src/Lho.Lambda.Observability/SentryTelemetry.cs
index c352b60..cb8ef81 100644
--- a/src/Lho.Lambda.Observability/SentryTelemetry.cs
+++ b/src/Lho.Lambda.Observability/SentryTelemetry.cs
@@ -1,34 +1,39 @@
using Amazon.Lambda.Core;
+using Lho.Lambda.RuntimeConfiguration.Options;
namespace Lho.Lambda.Observability;
public static class SentryTelemetry
{
+ // Happy-path flushes sit on the request critical path; keep the wait short.
+ private static readonly TimeSpan FlushTimeout = TimeSpan.FromMilliseconds(500);
+ private static readonly TimeSpan ErrorFlushTimeout = TimeSpan.FromSeconds(2);
private static readonly Lock InitLock = new();
private static bool _initialised;
- public static bool Initialise(ILambdaLogger logger)
+ public static bool Initialise(ObservabilityOptions options, ILambdaLogger logger)
{
- return EnsureInitialised(logger);
+ return EnsureInitialised(options, logger);
}
public static SentryTransactionScope? StartTransaction(
+ ObservabilityOptions options,
ILambdaLogger logger,
string name,
string operation,
IReadOnlyDictionary tags)
{
- if (!EnsureInitialised(logger))
+ if (!EnsureInitialised(options, logger))
{
return null;
}
var transaction = SentrySdk.StartTransaction(name, operation);
- ApplyTags(transaction, tags);
+ ApplyTags(transaction, options, tags);
SentrySdk.ConfigureScope(scope =>
{
scope.Transaction = transaction;
- ApplyTags(scope, tags);
+ ApplyTags(scope, options, tags);
});
return new SentryTransactionScope(transaction);
@@ -36,47 +41,48 @@ public static bool Initialise(ILambdaLogger logger)
public static async Task CaptureExceptionAsync(
Exception exception,
+ ObservabilityOptions options,
ILambdaLogger logger,
IReadOnlyDictionary tags)
{
- if (!EnsureInitialised(logger))
+ if (!EnsureInitialised(options, logger))
{
return;
}
SentrySdk.CaptureException(exception, scope =>
{
- ApplyTags(scope, tags);
+ ApplyTags(scope, options, tags);
});
- await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2));
+ await SentrySdk.FlushAsync(ErrorFlushTimeout);
}
- public static async Task RecordInvocationAsync(InvocationMetric metric, ILambdaLogger logger)
+ public static async Task RecordInvocationAsync(InvocationMetric metric, ObservabilityOptions options, ILambdaLogger logger)
{
- if (!EnsureInitialised(logger))
+ if (!EnsureInitialised(options, logger))
{
return;
}
- var attributes = MetricAttributes(metric);
+ var attributes = MetricAttributes(metric, options);
SentrySdk.Metrics.EmitCounter("lambda.invocation", 1, attributes, null);
SentrySdk.Metrics.EmitDistribution("lambda.invocation.duration", metric.DurationMs, MeasurementUnit.Duration.Millisecond, attributes, null);
- await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2));
+ await SentrySdk.FlushAsync(FlushTimeout);
}
- public static async Task FlushAsync(ILambdaLogger logger)
+ public static async Task FlushAsync(ObservabilityOptions options, ILambdaLogger logger)
{
- if (!EnsureInitialised(logger))
+ if (!EnsureInitialised(options, logger))
{
return;
}
- await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2));
+ await SentrySdk.FlushAsync(FlushTimeout);
}
- private static bool EnsureInitialised(ILambdaLogger logger)
+ private static bool EnsureInitialised(ObservabilityOptions options, ILambdaLogger logger)
{
lock (InitLock)
{
@@ -86,7 +92,7 @@ private static bool EnsureInitialised(ILambdaLogger logger)
}
}
- var dsn = ObservabilityConfig.SentryDsn;
+ var dsn = options.SentryDsn;
if (string.IsNullOrWhiteSpace(dsn))
{
return false;
@@ -101,17 +107,17 @@ private static bool EnsureInitialised(ILambdaLogger logger)
try
{
- SentrySdk.Init(options =>
+ SentrySdk.Init(sentryOptions =>
{
- options.Dsn = dsn;
- options.Environment = ObservabilityConfig.SentryEnvironment;
- options.Release = ObservabilityConfig.SentryRelease;
- options.AttachStacktrace = true;
- options.SampleRate = 1.0f;
- options.EnableMetrics = true;
- options.MaxBreadcrumbs = 50;
- options.TracesSampleRate = ObservabilityConfig.SentryTracesSampleRate;
- options.SendDefaultPii = false;
+ sentryOptions.Dsn = dsn;
+ sentryOptions.Environment = options.SentryEnvironment;
+ sentryOptions.Release = options.SentryRelease;
+ sentryOptions.AttachStacktrace = true;
+ sentryOptions.SampleRate = 1.0f;
+ sentryOptions.EnableMetrics = true;
+ sentryOptions.MaxBreadcrumbs = 50;
+ sentryOptions.TracesSampleRate = options.SentryTracesSampleRate;
+ sentryOptions.SendDefaultPii = false;
});
_initialised = true;
}
@@ -125,41 +131,41 @@ private static bool EnsureInitialised(ILambdaLogger logger)
return true;
}
- private static Dictionary MetricAttributes(InvocationMetric metric)
+ private static Dictionary MetricAttributes(InvocationMetric metric, ObservabilityOptions options)
{
var attributes = new Dictionary
{
- ["service"] = ObservabilityConfig.ServiceName,
- ["environment"] = ObservabilityConfig.EnvironmentName,
- ["version"] = ObservabilityConfig.Version,
- ["git_sha"] = ObservabilityConfig.GitSha,
- ["function"] = metric.FunctionName,
- ["operation"] = metric.Operation,
- ["route"] = metric.Route,
- ["method"] = metric.Method,
- ["status"] = metric.StatusCode.ToString(),
- ["outcome"] = metric.Outcome
+ ["service"] = options.ServiceName,
+ ["environment"] = options.EnvironmentName,
+ ["version"] = options.Version,
+ ["git_sha"] = options.GitSha,
+ [InvocationTags.Function] = metric.FunctionName,
+ [InvocationTags.Operation] = metric.Operation,
+ [InvocationTags.Route] = metric.Route,
+ [InvocationTags.Method] = metric.Method,
+ [InvocationTags.StatusCode] = metric.StatusCode.ToString(),
+ [InvocationTags.Outcome] = metric.Outcome
};
if (!string.IsNullOrWhiteSpace(metric.Consumer))
{
- attributes["consumer"] = metric.Consumer;
+ attributes[InvocationTags.Consumer] = metric.Consumer;
}
if (!string.IsNullOrWhiteSpace(metric.Provider))
{
- attributes["provider"] = metric.Provider;
+ attributes[InvocationTags.Provider] = metric.Provider;
}
return attributes;
}
- private static void ApplyTags(IHasTags target, IReadOnlyDictionary tags)
+ private static void ApplyTags(IHasTags target, ObservabilityOptions options, IReadOnlyDictionary tags)
{
- target.SetTag("service", ObservabilityConfig.ServiceName);
- target.SetTag("environment", ObservabilityConfig.SentryEnvironment);
- target.SetTag("version", ObservabilityConfig.SentryRelease);
- target.SetTag("git_sha", ObservabilityConfig.GitSha);
+ target.SetTag("service", options.ServiceName);
+ target.SetTag("environment", options.SentryEnvironment);
+ target.SetTag("version", options.SentryRelease);
+ target.SetTag("git_sha", options.GitSha);
foreach (var (key, value) in tags)
{
@@ -187,6 +193,17 @@ public sealed class SentryTransactionScope(ITransactionTracer transaction) : IDi
{
private bool _finished;
+ public void SetTag(string key, string value)
+ {
+ if (_finished)
+ {
+ return;
+ }
+
+ transaction.SetTag(key, value);
+ SentrySdk.ConfigureScope(scope => scope.SetTag(key, value));
+ }
+
public void Finish(int statusCode, Exception? exception = null)
{
if (_finished)
diff --git a/src/Lho.Lambda.Observability/StructuredLog.cs b/src/Lho.Lambda.Observability/StructuredLog.cs
index cece61c..c4185e5 100644
--- a/src/Lho.Lambda.Observability/StructuredLog.cs
+++ b/src/Lho.Lambda.Observability/StructuredLog.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using Amazon.Lambda.Core;
+using Lho.Lambda.RuntimeConfiguration.Options;
namespace Lho.Lambda.Observability;
@@ -10,33 +11,33 @@ public static class StructuredLog
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
- public static void Info(ILambdaLogger logger, string eventName, IReadOnlyDictionary fields)
+ public static void Info(ILambdaLogger logger, string eventName, ObservabilityOptions options, IReadOnlyDictionary fields)
{
- Write(logger, "info", eventName, fields);
+ Write(logger, "info", eventName, options, fields);
}
- public static void Error(ILambdaLogger logger, string eventName, Exception exception, IReadOnlyDictionary fields)
+ public static void Error(ILambdaLogger logger, string eventName, Exception exception, ObservabilityOptions options, IReadOnlyDictionary fields)
{
var enrichedFields = new Dictionary(fields)
{
- ["errorType"] = exception.GetType().Name,
- ["errorMessage"] = exception.Message
+ [InvocationTags.ErrorType] = exception.GetType().Name,
+ [InvocationTags.ErrorMessage] = exception.Message
};
- Write(logger, "error", eventName, enrichedFields);
+ Write(logger, "error", eventName, options, enrichedFields);
}
- private static void Write(ILambdaLogger logger, string level, string eventName, IReadOnlyDictionary fields)
+ private static void Write(ILambdaLogger logger, string level, string eventName, ObservabilityOptions options, IReadOnlyDictionary fields)
{
var payload = new Dictionary
{
["timestamp"] = DateTimeOffset.UtcNow.ToString("O"),
["level"] = level,
["event"] = eventName,
- ["service"] = ObservabilityConfig.ServiceName,
- ["environment"] = ObservabilityConfig.EnvironmentName,
- ["version"] = ObservabilityConfig.Version,
- ["gitSha"] = ObservabilityConfig.GitSha
+ ["service"] = options.ServiceName,
+ ["environment"] = options.EnvironmentName,
+ ["version"] = options.Version,
+ ["git_sha"] = options.GitSha
};
foreach (var (key, value) in fields)
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Consumers.cs b/src/Lho.Lambda.RuntimeConfiguration/Consumers.cs
new file mode 100644
index 0000000..fba240d
--- /dev/null
+++ b/src/Lho.Lambda.RuntimeConfiguration/Consumers.cs
@@ -0,0 +1,24 @@
+namespace Lho.Lambda.RuntimeConfiguration;
+
+public static class Consumers
+{
+ public const string Unknown = "unknown";
+
+ public static readonly IReadOnlySet Valid =
+ new HashSet(StringComparer.Ordinal) { "lhowsam-dev", "lhowsam-prod", "lhowsam-local" };
+
+ public static bool IsValid(string consumer)
+ {
+ return Valid.Contains(consumer);
+ }
+
+ public static string? Normalise(string? consumer)
+ {
+ return consumer switch
+ {
+ null or "" => null,
+ _ when Valid.Contains(consumer) => consumer,
+ _ => Unknown
+ };
+ }
+}
diff --git a/src/Lho.Lambda.RuntimeConfiguration/Options/ObservabilityOptions.cs b/src/Lho.Lambda.RuntimeConfiguration/Options/ObservabilityOptions.cs
index b68e08d..fa4b367 100644
--- a/src/Lho.Lambda.RuntimeConfiguration/Options/ObservabilityOptions.cs
+++ b/src/Lho.Lambda.RuntimeConfiguration/Options/ObservabilityOptions.cs
@@ -18,15 +18,5 @@ public class ObservabilityOptions
public string? SentryDsn { get; init; }
- public string? PushgatewayUrl { get; init; }
-
- public string? PushgatewayAuthHeader { get; init; }
-
- public string PushgatewayJob { get; init; } = "now-playing";
-
- public bool MetricsEnabledFlag { get; init; } = true;
-
public double SentryTracesSampleRate { get; init; } = 0.5;
-
- public bool MetricsEnabled => MetricsEnabledFlag && !string.IsNullOrEmpty(PushgatewayUrl);
}
diff --git a/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfig.cs b/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfig.cs
index 087f240..d4666e4 100644
--- a/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfig.cs
+++ b/src/Lho.Lambda.RuntimeConfiguration/RuntimeConfig.cs
@@ -16,7 +16,9 @@ public class RuntimeConfig
public FeatureFlagsOptions Features { get; init; } = new();
- public static RuntimeConfig Current => FromEnvironment(Environment.GetEnvironmentVariable);
+ private static readonly Lazy Cached = new(() => FromEnvironment(Environment.GetEnvironmentVariable));
+
+ public static RuntimeConfig Current => Cached.Value;
public static RuntimeConfig FromEnvironment(IReadOnlyDictionary environment)
{
@@ -59,10 +61,6 @@ public static RuntimeConfig FromEnvironment(Func getEnvironment
SentryEnvironment = String(getEnvironmentVariable, "SENTRY_ENVIRONMENT", environmentName),
SentryRelease = String(getEnvironmentVariable, "SENTRY_RELEASE", version),
SentryDsn = OptionalString(getEnvironmentVariable, "SENTRY_DSN"),
- PushgatewayUrl = OptionalString(getEnvironmentVariable, "PUSHGATEWAY_URL"),
- PushgatewayAuthHeader = OptionalString(getEnvironmentVariable, "PUSHGATEWAY_AUTH_HEADER"),
- PushgatewayJob = String(getEnvironmentVariable, "PROMETHEUS_JOB", serviceName),
- MetricsEnabledFlag = Bool(getEnvironmentVariable, "METRICS_ENABLED", defaultValue: true),
SentryTracesSampleRate = Double(getEnvironmentVariable, "SENTRY_TRACES_SAMPLE_RATE", defaultValue: 0.5)
},
Authorizer = new AuthorizerOptions
diff --git a/src/Lho.Lambda.Tests/ApiFunctionTests.cs b/src/Lho.Lambda.Tests/ApiFunctionTests.cs
index 97b78df..cbf7115 100644
--- a/src/Lho.Lambda.Tests/ApiFunctionTests.cs
+++ b/src/Lho.Lambda.Tests/ApiFunctionTests.cs
@@ -1,5 +1,4 @@
using System.Net;
-using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using Amazon.Lambda.APIGatewayEvents;
@@ -72,7 +71,6 @@ public async Task NowPlayingLastFmProviderUsesMainLastFmPath()
[Fact]
public async Task NowPlayingSpotifyProviderUsesSpotifyPath()
{
- Environment.SetEnvironmentVariable("SHOULD_CALL_SPOTIFY", "true");
var function = CreateFunction(
spotifyHandler: new StaticJsonHandler("""
{
@@ -116,7 +114,6 @@ public async Task NowPlayingSpotifyProviderUsesSpotifyPath()
[Fact]
public async Task NowPlayingUsesInjectedFeatureFlags()
{
- Environment.SetEnvironmentVariable("SHOULD_CALL_SPOTIFY", "true");
var spotifyHandler = new StaticJsonHandler("""
{
"is_playing": true,
@@ -156,41 +153,17 @@ public async Task NowPlayingUsesInjectedFeatureFlags()
Assert.Equal(0, spotifyHandler.RequestCount);
}
- [Fact]
- public async Task ApiHealthAndVersionRoutesPushInvocationMetrics()
+ [Theory]
+ [InlineData("/api/health", "GET")]
+ [InlineData("/api/health", "HEAD")]
+ [InlineData("/api/version", "GET")]
+ public async Task ApiHealthAndVersionRoutesReturnOk(string path, string method)
{
- var port = GetFreePort();
- using var listener = new HttpListener();
- listener.Prefixes.Add($"http://127.0.0.1:{port}/");
- listener.Start();
-
- ConfigureMetrics(port);
- try
- {
- var function = new ApiFunction();
+ var function = new ApiFunction();
- var healthGetMetric = await InvokeAndReadMetric(listener, function, CreateRequest("/api/health"));
- var healthHeadMetric = await InvokeAndReadMetric(listener, function, CreateRequest("/api/health", "HEAD"));
- var versionMetric = await InvokeAndReadMetric(listener, function, CreateRequest("/api/version"));
+ var response = await function.FunctionHandler(CreateRequest(path, method), new TestLambdaContext());
- Assert.Contains("route=\"/api/health\"", healthGetMetric);
- Assert.Contains("method=\"GET\"", healthGetMetric);
- Assert.Contains("status=\"200\"", healthGetMetric);
- Assert.Contains("route=\"/api/health\"", healthHeadMetric);
- Assert.Contains("method=\"HEAD\"", healthHeadMetric);
- Assert.Contains("status=\"200\"", healthHeadMetric);
- Assert.Contains("route=\"/api/version\"", versionMetric);
- Assert.Contains("method=\"GET\"", versionMetric);
- Assert.Contains("status=\"200\"", versionMetric);
- }
- finally
- {
- Environment.SetEnvironmentVariable("METRICS_ENABLED", null);
- Environment.SetEnvironmentVariable("PUSHGATEWAY_URL", null);
- Environment.SetEnvironmentVariable("PUSHGATEWAY_AUTH_HEADER", null);
- Environment.SetEnvironmentVariable("PROMETHEUS_JOB", null);
- Environment.SetEnvironmentVariable("ENVIRONMENT", null);
- }
+ Assert.Equal((int)HttpStatusCode.OK, response.StatusCode);
}
private static APIGatewayHttpApiV2ProxyRequest CreateRequest(
@@ -213,15 +186,6 @@ private static APIGatewayHttpApiV2ProxyRequest CreateRequest(
};
}
- private static void ConfigureMetrics(int port)
- {
- Environment.SetEnvironmentVariable("METRICS_ENABLED", "true");
- Environment.SetEnvironmentVariable("PUSHGATEWAY_URL", $"http://127.0.0.1:{port}");
- Environment.SetEnvironmentVariable("PUSHGATEWAY_AUTH_HEADER", null);
- Environment.SetEnvironmentVariable("PROMETHEUS_JOB", "test-job");
- Environment.SetEnvironmentVariable("ENVIRONMENT", "test");
- }
-
private static ApiFunction CreateFunction(StaticJsonHandler spotifyHandler, StaticJsonHandler lastFmHandler)
{
var spotifyApi = new SpotifyApi(
@@ -249,33 +213,6 @@ private static HttpClient CreateHttpClient(HttpMessageHandler handler, string ba
};
}
- private static async Task InvokeAndReadMetric(
- HttpListener listener,
- ApiFunction function,
- APIGatewayHttpApiV2ProxyRequest request)
- {
- var requestTask = listener.GetContextAsync();
- var responseTask = function.FunctionHandler(request, new TestLambdaContext());
-
- var context = await requestTask.WaitAsync(TimeSpan.FromSeconds(2));
- using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
- var body = await reader.ReadToEndAsync();
- context.Response.StatusCode = (int)HttpStatusCode.Accepted;
- context.Response.Close();
-
- var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(2));
- Assert.Equal((int)HttpStatusCode.OK, response.StatusCode);
-
- return body;
- }
-
- private static int GetFreePort()
- {
- using var listener = new TcpListener(IPAddress.Loopback, 0);
- listener.Start();
- return ((IPEndPoint)listener.LocalEndpoint).Port;
- }
-
private sealed class StaticJsonHandler(string json) : HttpMessageHandler
{
public int RequestCount { get; private set; }
diff --git a/src/Lho.Lambda.Tests/AuthorizerFunctionTests.cs b/src/Lho.Lambda.Tests/AuthorizerFunctionTests.cs
index 90b6ad5..1de8441 100644
--- a/src/Lho.Lambda.Tests/AuthorizerFunctionTests.cs
+++ b/src/Lho.Lambda.Tests/AuthorizerFunctionTests.cs
@@ -10,8 +10,7 @@ public class AuthorizerFunctionTests
[Fact]
public async Task AuthorizesKnownConsumerWithMatchingApiKey()
{
- Environment.SetEnvironmentVariable("API_KEY", "secret");
- var function = new AuthorizerFunction();
+ var function = CreateFunction(apiKey: "secret");
var response = await function.FunctionHandler(
CreateRequest(new Dictionary
@@ -27,8 +26,7 @@ public async Task AuthorizesKnownConsumerWithMatchingApiKey()
[Fact]
public async Task DeniesUnknownConsumer()
{
- Environment.SetEnvironmentVariable("API_KEY", "secret");
- var function = new AuthorizerFunction();
+ var function = CreateFunction(apiKey: "secret");
var response = await function.FunctionHandler(
CreateRequest(new Dictionary
@@ -44,8 +42,7 @@ public async Task DeniesUnknownConsumer()
[Fact]
public async Task DeniesMismatchedApiKey()
{
- Environment.SetEnvironmentVariable("API_KEY", "secret");
- var function = new AuthorizerFunction();
+ var function = CreateFunction(apiKey: "secret");
var response = await function.FunctionHandler(
CreateRequest(new Dictionary
@@ -60,8 +57,7 @@ public async Task DeniesMismatchedApiKey()
[Fact]
public async Task DeniesMissingApiKeyConfiguration()
{
- Environment.SetEnvironmentVariable("API_KEY", null);
- var function = new AuthorizerFunction();
+ var function = CreateFunction(apiKey: null);
var response = await function.FunctionHandler(
CreateRequest([]),
@@ -70,20 +66,11 @@ public async Task DeniesMissingApiKeyConfiguration()
Assert.False(response.IsAuthorized);
}
- [Fact]
- public async Task UsesInjectedAuthorizerOptions()
+ private static AuthorizerFunction CreateFunction(string? apiKey)
{
- Environment.SetEnvironmentVariable("API_KEY", "environment-secret");
- var function = new AuthorizerFunction(new AuthorizerOptions { ApiKey = "injected-secret" });
-
- var response = await function.FunctionHandler(
- CreateRequest(new Dictionary
- {
- ["x-api-key"] = "injected-secret"
- }),
- new TestLambdaContext());
-
- Assert.True(response.IsAuthorized);
+ return new AuthorizerFunction(
+ new AuthorizerOptions { ApiKey = apiKey },
+ new ObservabilityOptions());
}
private static AuthorizerRequest CreateRequest(Dictionary headers)
diff --git a/src/Lho.Lambda.Tests/LambdaSerializerTests.cs b/src/Lho.Lambda.Tests/LambdaSerializerTests.cs
new file mode 100644
index 0000000..5b3c6fd
--- /dev/null
+++ b/src/Lho.Lambda.Tests/LambdaSerializerTests.cs
@@ -0,0 +1,83 @@
+using System.Text;
+using System.Text.Json;
+using Amazon.Lambda.APIGatewayEvents;
+using Amazon.Lambda.Core;
+using Amazon.Lambda.Serialization.SystemTextJson;
+using Lho.Lambda.Authorizer.Models;
+using Xunit;
+
+namespace Lho.Lambda.Tests;
+
+public class LambdaSerializerTests
+{
+ [Fact]
+ public void ApiSerializerRoundTripsHttpApiV2Events()
+ {
+ var serializer = new SourceGeneratorLambdaJsonSerializer();
+ const string requestJson = """
+ {
+ "rawPath": "/api/now-playing",
+ "rawQueryString": "provider=lastfm",
+ "headers": { "x-consumer": "lhowsam-prod" },
+ "requestContext": { "http": { "method": "GET", "path": "/api/now-playing" } }
+ }
+ """;
+
+ var request = serializer.Deserialize(ToStream(requestJson));
+
+ Assert.Equal("/api/now-playing", request.RawPath);
+ Assert.Equal("provider=lastfm", request.RawQueryString);
+ Assert.Equal("lhowsam-prod", request.Headers["x-consumer"]);
+ Assert.Equal("GET", request.RequestContext.Http.Method);
+
+ var response = new APIGatewayHttpApiV2ProxyResponse
+ {
+ StatusCode = 200,
+ Body = """{"status":"OK"}""",
+ Headers = new Dictionary { ["content-type"] = "application/json" }
+ };
+
+ var body = JsonDocument.Parse(Serialize(serializer, response)).RootElement;
+
+ Assert.Equal(200, body.GetProperty("statusCode").GetInt32());
+ Assert.Equal("""{"status":"OK"}""", body.GetProperty("body").GetString());
+ Assert.Equal("application/json", body.GetProperty("headers").GetProperty("content-type").GetString());
+ }
+
+ [Fact]
+ public void AuthorizerSerializerRoundTripsAuthorizerEvents()
+ {
+ var serializer = new SourceGeneratorLambdaJsonSerializer();
+ const string requestJson = """
+ {
+ "version": "2.0",
+ "type": "REQUEST",
+ "routeKey": "GET /api/now-playing",
+ "headers": { "x-api-key": "secret", "x-consumer": "lhowsam-prod" },
+ "requestContext": { "http": { "method": "GET", "path": "/api/now-playing" } }
+ }
+ """;
+
+ var request = serializer.Deserialize(ToStream(requestJson));
+
+ Assert.Equal("GET /api/now-playing", request.RouteKey);
+ Assert.Equal("secret", request.Headers?["x-api-key"]);
+ Assert.Equal("GET", request.RequestContext?.Http?.Method);
+
+ var body = JsonDocument.Parse(Serialize(serializer, new AuthorizerSimpleResponse(true))).RootElement;
+
+ Assert.True(body.GetProperty("isAuthorized").GetBoolean());
+ }
+
+ private static MemoryStream ToStream(string json)
+ {
+ return new MemoryStream(Encoding.UTF8.GetBytes(json));
+ }
+
+ private static string Serialize(ILambdaSerializer serializer, T value)
+ {
+ using var stream = new MemoryStream();
+ serializer.Serialize(value, stream);
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+}
diff --git a/src/Lho.Lambda.Tests/NowPlayingServiceTests.cs b/src/Lho.Lambda.Tests/NowPlayingServiceTests.cs
index 0716af2..fab8fd6 100644
--- a/src/Lho.Lambda.Tests/NowPlayingServiceTests.cs
+++ b/src/Lho.Lambda.Tests/NowPlayingServiceTests.cs
@@ -35,18 +35,64 @@ public async Task NowPlayingUsesLastFmAndCachesResult()
spotifyHandler: new StaticJsonHandler("{}"),
lastFmHandler: lastFmHandler);
- var first = await service.GetNowPlaying();
- var second = await service.GetNowPlaying();
-
- Assert.True(first.IsPlaying);
- Assert.Equal("Last.fm song", first.Title);
- Assert.Equal("Last.fm artist", first.Artist);
- Assert.Equal("Last.fm album", first.Album);
- Assert.Equal("https://example.com/large.jpg", first.AlbumImageUrl);
- Assert.Same(first, second);
+ var first = await service.HandleNowPlaying(null);
+ var second = await service.HandleNowPlaying(null);
+
+ Assert.Equal("lastfm", first.Provider);
+ Assert.True(first.Response.IsPlaying);
+ Assert.Equal("Last.fm song", first.Response.Title);
+ Assert.Equal("Last.fm artist", first.Response.Artist);
+ Assert.Equal("Last.fm album", first.Response.Album);
+ Assert.Equal("https://example.com/large.jpg", first.Response.AlbumImageUrl);
+ Assert.Same(first.Response, second.Response);
Assert.Equal(1, lastFmHandler.RequestCount);
}
+ [Fact]
+ public async Task NowPlayingCachesEmptyResponseWhenNothingIsPlaying()
+ {
+ var lastFmHandler = new StaticJsonHandler("""
+ {
+ "recenttracks": {
+ "track": [{
+ "name": "Older song",
+ "artist": { "#text": "Older artist" },
+ "album": { "#text": "Older album" },
+ "url": "https://last.fm/track/older",
+ "image": []
+ }]
+ }
+ }
+ """);
+ var service = CreateService(
+ spotifyHandler: new StaticJsonHandler("{}"),
+ lastFmHandler: lastFmHandler);
+
+ var first = await service.HandleNowPlaying(null);
+ var second = await service.HandleNowPlaying(null);
+
+ Assert.False(first.Response.IsPlaying);
+ Assert.Equal(200, first.Response.Status);
+ Assert.Same(first.Response, second.Response);
+ Assert.Equal(1, lastFmHandler.RequestCount);
+ }
+
+ [Fact]
+ public async Task NowPlayingDoesNotCacheFailureResponses()
+ {
+ var lastFmHandler = new StaticJsonHandler("lastfm failed", HttpStatusCode.InternalServerError);
+ var service = CreateService(
+ spotifyHandler: new StaticJsonHandler("{}"),
+ lastFmHandler: lastFmHandler);
+
+ var first = await service.HandleNowPlaying(null);
+ var second = await service.HandleNowPlaying(null);
+
+ Assert.Equal(500, first.Response.Status);
+ Assert.Equal(500, second.Response.Status);
+ Assert.Equal(2, lastFmHandler.RequestCount);
+ }
+
[Fact]
public async Task NowPlayingFailureReturnsEmptyStatus500ViewModel()
{
@@ -54,7 +100,8 @@ public async Task NowPlayingFailureReturnsEmptyStatus500ViewModel()
spotifyHandler: new StaticJsonHandler("{}"),
lastFmHandler: new StaticJsonHandler("lastfm failed", HttpStatusCode.InternalServerError));
- var response = await service.GetNowPlaying();
+ var result = await service.HandleNowPlaying(null);
+ var response = result.Response;
Assert.False(response.IsPlaying);
Assert.False(response.Maintenance);
@@ -69,7 +116,6 @@ public async Task NowPlayingFailureReturnsEmptyStatus500ViewModel()
[Fact]
public async Task SpotifyNowPlayingUsesSpotifyAndCachesResult()
{
- Environment.SetEnvironmentVariable("SHOULD_CALL_SPOTIFY", "true");
var spotifyHandler = new StaticJsonHandler("""
{
"is_playing": true,
@@ -88,22 +134,22 @@ public async Task SpotifyNowPlayingUsesSpotifyAndCachesResult()
spotifyHandler: spotifyHandler,
lastFmHandler: new StaticJsonHandler("{}"));
- var first = await service.GetSpotifyNowPlaying();
- var second = await service.GetSpotifyNowPlaying();
+ var first = await service.HandleNowPlaying("spotify");
+ var second = await service.HandleNowPlaying("spotify");
- Assert.True(first.IsPlaying);
- Assert.Equal("Spotify song", first.Title);
- Assert.Equal("Spotify artist", first.Artist);
- Assert.Equal("Spotify album", first.Album);
- Assert.Equal("https://example.com/spotify.jpg", first.AlbumImageUrl);
- Assert.Same(first, second);
+ Assert.Equal("spotify", first.Provider);
+ Assert.True(first.Response.IsPlaying);
+ Assert.Equal("Spotify song", first.Response.Title);
+ Assert.Equal("Spotify artist", first.Response.Artist);
+ Assert.Equal("Spotify album", first.Response.Album);
+ Assert.Equal("https://example.com/spotify.jpg", first.Response.AlbumImageUrl);
+ Assert.Same(first.Response, second.Response);
Assert.Equal(1, spotifyHandler.RequestCount);
}
[Fact]
public async Task SpotifyReturnsEmptyResponseWhenItemIsNotPlaying()
{
- Environment.SetEnvironmentVariable("SHOULD_CALL_SPOTIFY", "true");
var spotifyApi = new SpotifyApi(
CreateHttpClient(new StaticJsonHandler("""
{
@@ -128,9 +174,10 @@ public async Task SpotifyReturnsEmptyResponseWhenItemIsNotPlaying()
spotifyApi,
lastFmApi,
new TestLambdaLogger(),
- new FeatureFlagsOptions { ShouldCallSpotify = true });
+ new FeatureFlagsOptions { ShouldCallSpotify = true },
+ new ObservabilityOptions());
- var response = await service.HandleNowPlaying("spotify");
+ var response = (await service.HandleNowPlaying("spotify")).Response;
Assert.False(response.IsPlaying);
Assert.False(response.Maintenance);
@@ -142,6 +189,50 @@ public async Task SpotifyReturnsEmptyResponseWhenItemIsNotPlaying()
Assert.Equal("", response.Title);
}
+ [Fact]
+ public async Task InvalidProviderFallsBackToLastFm()
+ {
+ var spotifyHandler = new StaticJsonHandler("{}");
+ var lastFmHandler = new StaticJsonHandler("""
+ { "recenttracks": { "track": [] } }
+ """);
+ var service = CreateService(spotifyHandler, lastFmHandler);
+
+ var result = await service.HandleNowPlaying("garbage");
+
+ Assert.Equal("lastfm", result.Provider);
+ Assert.Equal(1, lastFmHandler.RequestCount);
+ Assert.Equal(0, spotifyHandler.RequestCount);
+ }
+
+ [Fact]
+ public async Task SpotifyProviderIsCaseInsensitive()
+ {
+ var spotifyHandler = new StaticJsonHandler("""
+ {
+ "is_playing": true,
+ "item": {
+ "name": "Spotify song",
+ "artists": [{ "name": "Spotify artist" }],
+ "album": {
+ "name": "Spotify album",
+ "images": [{ "url": "https://example.com/spotify.jpg" }]
+ },
+ "external_urls": { "spotify": "https://open.spotify.com/track/spotify" }
+ }
+ }
+ """);
+ var service = CreateService(
+ spotifyHandler: spotifyHandler,
+ lastFmHandler: new StaticJsonHandler("{}"));
+
+ var result = await service.HandleNowPlaying("SPOTIFY");
+
+ Assert.Equal("spotify", result.Provider);
+ Assert.Equal("Spotify song", result.Response.Title);
+ Assert.Equal(1, spotifyHandler.RequestCount);
+ }
+
private static NowPlayingService CreateService(StaticJsonHandler spotifyHandler, StaticJsonHandler lastFmHandler)
{
var spotifyApi = new SpotifyApi(
@@ -156,7 +247,8 @@ private static NowPlayingService CreateService(StaticJsonHandler spotifyHandler,
spotifyApi,
lastFmApi,
new TestLambdaLogger(),
- new FeatureFlagsOptions { ShouldCallSpotify = true });
+ new FeatureFlagsOptions { ShouldCallSpotify = true },
+ new ObservabilityOptions());
}
private static HttpClient CreateHttpClient(HttpMessageHandler handler, string baseAddress = "https://api.spotify.test/v1/")
diff --git a/src/Lho.Lambda.Tests/PrometheusMetricsTests.cs b/src/Lho.Lambda.Tests/PrometheusMetricsTests.cs
deleted file mode 100644
index fcb9a9c..0000000
--- a/src/Lho.Lambda.Tests/PrometheusMetricsTests.cs
+++ /dev/null
@@ -1,126 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using Lho.Lambda.Observability;
-using Xunit;
-
-[assembly: CollectionBehavior(DisableTestParallelization = true)]
-
-namespace Lho.Lambda.Tests;
-
-public class PrometheusMetricsTests
-{
- [Fact]
- public async Task PushInvocationCountsEachProviderSeriesSeparately()
- {
- var port = GetFreePort();
- using var listener = new HttpListener();
- listener.Prefixes.Add($"http://127.0.0.1:{port}/");
- listener.Start();
-
- ConfigureMetrics(port);
-
- var lastFmBody = await PushAndReadBody(listener, new InvocationMetric(
- FunctionName: "provider-counter-test-function",
- Operation: "now-playing",
- Route: "/api/now-playing",
- Method: "GET",
- StatusCode: 200,
- DurationMs: 12,
- Outcome: "success",
- Provider: "lastfm"));
-
- var spotifyBody = await PushAndReadBody(listener, new InvocationMetric(
- FunctionName: "provider-counter-test-function",
- Operation: "now-playing",
- Route: "/api/now-playing",
- Method: "GET",
- StatusCode: 200,
- DurationMs: 14,
- Outcome: "success",
- Provider: "spotify"));
-
- Assert.Equal("1", InvocationTotalValue(lastFmBody));
- Assert.Contains("provider=\"lastfm\"", InvocationTotalLine(lastFmBody));
- Assert.Equal("1", InvocationTotalValue(spotifyBody));
- Assert.Contains("provider=\"spotify\"", InvocationTotalLine(spotifyBody));
- }
-
- [Fact]
- public async Task PushInvocationAddsConfiguredAuthorizationHeader()
- {
- var port = GetFreePort();
- using var listener = new HttpListener();
- listener.Prefixes.Add($"http://127.0.0.1:{port}/");
- listener.Start();
-
- ConfigureMetrics(port);
- Environment.SetEnvironmentVariable("PUSHGATEWAY_AUTH_HEADER", "Authorization=Basic dXNlcjpwYXNz");
-
- var requestTask = listener.GetContextAsync();
-
- var pushTask = PrometheusMetrics.PushInvocationAsync(
- new InvocationMetric(
- FunctionName: "test-function",
- Operation: "test-operation",
- Route: "/test",
- Method: "GET",
- StatusCode: 200,
- DurationMs: 12,
- Outcome: "success"),
- new TestLambdaLogger());
-
- var context = await requestTask.WaitAsync(TimeSpan.FromSeconds(2));
- var authorizationHeader = context.Request.Headers["Authorization"];
- context.Response.StatusCode = (int)HttpStatusCode.Accepted;
- context.Response.Close();
-
- await pushTask.WaitAsync(TimeSpan.FromSeconds(2));
-
- Assert.Equal("Basic dXNlcjpwYXNz", authorizationHeader);
- }
-
- private static void ConfigureMetrics(int port)
- {
- Environment.SetEnvironmentVariable("METRICS_ENABLED", "true");
- Environment.SetEnvironmentVariable("PUSHGATEWAY_URL", $"http://127.0.0.1:{port}");
- Environment.SetEnvironmentVariable("PUSHGATEWAY_AUTH_HEADER", null);
- Environment.SetEnvironmentVariable("PROMETHEUS_JOB", "test-job");
- Environment.SetEnvironmentVariable("ENVIRONMENT", "test");
- }
-
- private static async Task PushAndReadBody(HttpListener listener, InvocationMetric metric)
- {
- var requestTask = listener.GetContextAsync();
- var pushTask = PrometheusMetrics.PushInvocationAsync(metric, new TestLambdaLogger());
-
- var context = await requestTask.WaitAsync(TimeSpan.FromSeconds(2));
- using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
- var body = await reader.ReadToEndAsync();
- context.Response.StatusCode = (int)HttpStatusCode.Accepted;
- context.Response.Close();
-
- await pushTask.WaitAsync(TimeSpan.FromSeconds(2));
-
- return body;
- }
-
- private static string InvocationTotalValue(string body)
- {
- var line = InvocationTotalLine(body);
- return line[(line.LastIndexOf(' ') + 1)..];
- }
-
- private static string InvocationTotalLine(string body)
- {
- return body
- .Split('\n', StringSplitOptions.RemoveEmptyEntries)
- .Single(line => line.StartsWith("lho_lambda_invocations_total{", StringComparison.Ordinal));
- }
-
- private static int GetFreePort()
- {
- using var listener = new TcpListener(IPAddress.Loopback, 0);
- listener.Start();
- return ((IPEndPoint)listener.LocalEndpoint).Port;
- }
-}
diff --git a/src/Lho.Lambda.Tests/RuntimeConfigurationTests.cs b/src/Lho.Lambda.Tests/RuntimeConfigurationTests.cs
index e04fa9e..3603bfc 100644
--- a/src/Lho.Lambda.Tests/RuntimeConfigurationTests.cs
+++ b/src/Lho.Lambda.Tests/RuntimeConfigurationTests.cs
@@ -18,8 +18,6 @@ public void FromEnvironmentBuildsTypedSections()
["DEPLOYED_BY"] = "tests",
["GIT_SHA"] = "abc123",
["SENTRY_DSN"] = "https://sentry.example",
- ["PUSHGATEWAY_URL"] = "https://pushgateway.example",
- ["PUSHGATEWAY_AUTH_HEADER"] = "Authorization=Basic abc",
["API_KEY"] = "authorizer-key",
["SHOULD_CALL_SPOTIFY"] = "false"
});
@@ -32,8 +30,6 @@ public void FromEnvironmentBuildsTypedSections()
Assert.Equal("tests", config.Deployment.DeployedBy);
Assert.Equal("abc123", config.Deployment.GitSha);
Assert.Equal("https://sentry.example", config.Observability.SentryDsn);
- Assert.Equal("https://pushgateway.example", config.Observability.PushgatewayUrl);
- Assert.Equal("Authorization=Basic abc", config.Observability.PushgatewayAuthHeader);
Assert.Equal("authorizer-key", config.Authorizer.ApiKey);
Assert.False(config.Features.ShouldCallSpotify);
}
@@ -73,8 +69,25 @@ public void MissingObservabilitySinksDisableThem()
var config = RuntimeConfig.FromEnvironment(new Dictionary());
Assert.Null(config.Observability.SentryDsn);
- Assert.Null(config.Observability.PushgatewayUrl);
- Assert.False(config.Observability.MetricsEnabled);
+ }
+
+ [Theory]
+ [InlineData("lhowsam-prod", "lhowsam-prod")]
+ [InlineData("lhowsam-dev", "lhowsam-dev")]
+ [InlineData("lhowsam-local", "lhowsam-local")]
+ [InlineData(null, null)]
+ [InlineData("", null)]
+ [InlineData("someone-else", "unknown")]
+ public void ConsumerNormalisationMapsHeaderValues(string? consumer, string? expected)
+ {
+ Assert.Equal(expected, Consumers.Normalise(consumer));
+ }
+
+ [Fact]
+ public void UnknownConsumerIsNotValid()
+ {
+ Assert.True(Consumers.IsValid("lhowsam-prod"));
+ Assert.False(Consumers.IsValid(Consumers.Unknown));
}
[Fact]
diff --git a/src/Lho.Lambda.Tests/SpotifyApiTests.cs b/src/Lho.Lambda.Tests/SpotifyApiTests.cs
new file mode 100644
index 0000000..137fc30
--- /dev/null
+++ b/src/Lho.Lambda.Tests/SpotifyApiTests.cs
@@ -0,0 +1,146 @@
+using System.Net;
+using System.Text;
+using Lho.Lambda.Clients.Spotify;
+using Lho.Lambda.RuntimeConfiguration.Options;
+using Xunit;
+
+namespace Lho.Lambda.Tests;
+
+public class SpotifyApiTests
+{
+ private const string NowPlayingJson = """
+ {
+ "is_playing": true,
+ "item": {
+ "name": "Spotify song",
+ "artists": [{ "name": "Spotify artist" }],
+ "album": {
+ "name": "Spotify album",
+ "images": [{ "url": "https://example.com/spotify.jpg" }]
+ },
+ "external_urls": { "spotify": "https://open.spotify.com/track/spotify" }
+ }
+ }
+ """;
+
+ [Fact]
+ public async Task RefreshFlowRequestsTokenAndSendsBearer()
+ {
+ var handler = new RoutingHandler(tokenJson: TokenJson("tok-1"), apiJson: NowPlayingJson);
+ var api = CreateApi(handler, new MutableTimeProvider());
+
+ var response = await api.GetNowPlaying();
+
+ Assert.NotNull(response);
+ Assert.Equal(1, handler.TokenRequestCount);
+ Assert.Equal("Bearer tok-1", handler.LastAuthorizationHeader);
+ }
+
+ [Fact]
+ public async Task CachedTokenIsReusedAcrossCalls()
+ {
+ var handler = new RoutingHandler(tokenJson: TokenJson("tok-1"), apiJson: NowPlayingJson);
+ var api = CreateApi(handler, new MutableTimeProvider());
+
+ await api.GetNowPlaying();
+ await api.GetNowPlaying();
+
+ Assert.Equal(1, handler.TokenRequestCount);
+ Assert.Equal(2, handler.ApiRequestCount);
+ }
+
+ [Fact]
+ public async Task ExpiredTokenIsRefreshed()
+ {
+ var time = new MutableTimeProvider();
+ var handler = new RoutingHandler(tokenJson: TokenJson("tok-1", expiresIn: 3600), apiJson: NowPlayingJson);
+ var api = CreateApi(handler, time);
+
+ await api.GetNowPlaying();
+ // The token is cached for expires_in - 60 seconds; step past that window.
+ time.UtcNow = time.UtcNow.AddSeconds(3600);
+ await api.GetNowPlaying();
+
+ Assert.Equal(2, handler.TokenRequestCount);
+ }
+
+ [Fact]
+ public async Task StaticAccessTokenSkipsTokenEndpoint()
+ {
+ var handler = new RoutingHandler(tokenJson: TokenJson("tok-1"), apiJson: NowPlayingJson);
+ var api = new SpotifyApi(
+ CreateHttpClient(handler),
+ new SpotifyOptions { AccessToken = "static-token" });
+
+ await api.GetNowPlaying();
+
+ Assert.Equal(0, handler.TokenRequestCount);
+ Assert.Equal("Bearer static-token", handler.LastAuthorizationHeader);
+ }
+
+ private static string TokenJson(string accessToken, int expiresIn = 3600)
+ {
+ return $$"""{ "access_token": "{{accessToken}}", "expires_in": {{expiresIn}} }""";
+ }
+
+ private static SpotifyApi CreateApi(RoutingHandler handler, TimeProvider timeProvider)
+ {
+ return new SpotifyApi(
+ CreateHttpClient(handler),
+ new SpotifyOptions
+ {
+ ClientId = "client-id",
+ ClientSecret = "client-secret",
+ RefreshToken = "refresh-token"
+ },
+ timeProvider);
+ }
+
+ private static HttpClient CreateHttpClient(HttpMessageHandler handler)
+ {
+ return new HttpClient(handler)
+ {
+ BaseAddress = new Uri("https://api.spotify.test/v1/")
+ };
+ }
+
+ private sealed class MutableTimeProvider : TimeProvider
+ {
+ public DateTimeOffset UtcNow { get; set; } = new(2026, 7, 5, 12, 0, 0, TimeSpan.Zero);
+
+ public override DateTimeOffset GetUtcNow()
+ {
+ return UtcNow;
+ }
+ }
+
+ private sealed class RoutingHandler(string tokenJson, string apiJson) : HttpMessageHandler
+ {
+ public int TokenRequestCount { get; private set; }
+
+ public int ApiRequestCount { get; private set; }
+
+ public string? LastAuthorizationHeader { get; private set; }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ string body;
+ if (request.RequestUri?.Host == "accounts.spotify.com")
+ {
+ TokenRequestCount++;
+ body = tokenJson;
+ }
+ else
+ {
+ ApiRequestCount++;
+ LastAuthorizationHeader = request.Headers.Authorization?.ToString();
+ body = apiJson;
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json")
+ });
+ }
+ }
+}
diff --git a/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs b/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs
index 63f444e..5197ce4 100644
--- a/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs
+++ b/src/Lho.Lambda/Clients/LastFm/LastFmApi.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Lho.Lambda.Models;
using Lho.Lambda.RuntimeConfiguration.Options;
+using Lho.Lambda.Serialization;
namespace Lho.Lambda.Clients.LastFm;
@@ -8,7 +9,8 @@ public class LastFmApi
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
- PropertyNameCaseInsensitive = true
+ PropertyNameCaseInsensitive = true,
+ TypeInfoResolver = LastFmJsonContext.Default
};
private readonly HttpClient _httpClient;
diff --git a/src/Lho.Lambda/Clients/Spotify/SpotifyApi.cs b/src/Lho.Lambda/Clients/Spotify/SpotifyApi.cs
index b0925b4..ceceda0 100644
--- a/src/Lho.Lambda/Clients/Spotify/SpotifyApi.cs
+++ b/src/Lho.Lambda/Clients/Spotify/SpotifyApi.cs
@@ -4,6 +4,7 @@
using System.Text.Json;
using Lho.Lambda.Models;
using Lho.Lambda.RuntimeConfiguration.Options;
+using Lho.Lambda.Serialization;
namespace Lho.Lambda.Clients.Spotify;
@@ -11,18 +12,21 @@ public class SpotifyApi
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
- PropertyNameCaseInsensitive = true
+ PropertyNameCaseInsensitive = true,
+ TypeInfoResolver = SpotifyJsonContext.Default
};
private readonly HttpClient _httpClient;
private readonly SpotifyOptions _spotifyOptions;
+ private readonly TimeProvider _timeProvider;
private string? _cachedAccessToken;
private DateTimeOffset? _tokenExpiresAt;
- public SpotifyApi(HttpClient httpClient, SpotifyOptions spotifyOptions)
+ public SpotifyApi(HttpClient httpClient, SpotifyOptions spotifyOptions, TimeProvider? timeProvider = null)
{
_httpClient = httpClient;
_spotifyOptions = spotifyOptions;
+ _timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task GetNowPlaying()
@@ -63,7 +67,7 @@ private async Task GetAccessToken()
return _spotifyOptions.AccessToken;
}
- if (!string.IsNullOrEmpty(_cachedAccessToken) && _tokenExpiresAt > DateTimeOffset.UtcNow)
+ if (!string.IsNullOrEmpty(_cachedAccessToken) && _tokenExpiresAt > _timeProvider.GetUtcNow())
{
return _cachedAccessToken;
}
@@ -86,7 +90,7 @@ private async Task GetAccessToken()
?? throw new SpotifyServiceException("Empty response from token endpoint");
_cachedAccessToken = tokenResponse.AccessToken;
- _tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60);
+ _tokenExpiresAt = _timeProvider.GetUtcNow().AddSeconds(tokenResponse.ExpiresIn - 60);
return tokenResponse.AccessToken;
}
diff --git a/src/Lho.Lambda/Functions/ApiFunction.cs b/src/Lho.Lambda/Functions/ApiFunction.cs
index 0ad5ce1..e8aa368 100644
--- a/src/Lho.Lambda/Functions/ApiFunction.cs
+++ b/src/Lho.Lambda/Functions/ApiFunction.cs
@@ -1,8 +1,9 @@
-using System.Diagnostics;
+using System.Web;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Lho.Lambda.Clients.LastFm;
using Lho.Lambda.Clients.Spotify;
+using Lho.Lambda.Models;
using Lho.Lambda.Observability;
using Lho.Lambda.RuntimeConfiguration;
using Lho.Lambda.Services;
@@ -47,25 +48,16 @@ public async Task FunctionHandler(
ILambdaContext ctx
)
{
- var stopwatch = Stopwatch.StartNew();
var method = request.RequestContext?.Http?.Method ?? "GET";
var path = NormalisePath(request.RawPath);
- var consumer = NormaliseConsumer(GetHeaderValue(request.Headers, "x-consumer"));
- var provider = path == "/api/now-playing"
- ? QueryStringParser.Get(request.RawQueryString, "provider", "lastfm", ["lastfm", "spotify"])
- : null;
+ var consumer = Consumers.Normalise(GetHeaderValue(request.Headers, "x-consumer"));
+
+ await using var invocation = LambdaInvocation.Begin(
+ ctx,
+ new InvocationDescriptor("api", path, method, consumer),
+ _runtimeConfig.Observability);
+
APIGatewayHttpApiV2ProxyResponse response;
- Exception? capturedException = null;
- SentryTelemetry.Initialise(ctx.Logger);
- using var transaction = SentryTelemetry.StartTransaction(ctx.Logger, $"{method} {path}", "http.server", new Dictionary
- {
- ["function"] = ctx.FunctionName,
- ["request_id"] = ctx.AwsRequestId,
- ["route"] = path,
- ["method"] = method,
- ["consumer"] = consumer,
- ["provider"] = provider
- });
try
{
@@ -77,116 +69,100 @@ ILambdaContext ctx
{
response = path switch
{
- "/api/health" => ResponseBuilder.CreateResponse(new { status = "OK" }, includeCacheControl: false),
- "/api/version" => ResponseBuilder.CreateResponse(new VersionService(_runtimeConfig.Deployment).GetVersion(), includeCacheControl: false),
- "/api/now-playing" => await HandleNowPlaying(ctx, provider!),
- "/api/top-tracks" => await HandleTopTracks(request, ctx),
+ "/api/health" => ResponseBuilder.CreateResponse(new HealthResponse("OK"), includeCacheControl: false),
+ "/api/version" => ResponseBuilder.CreateResponse(BuildVersionResponse(), includeCacheControl: false),
+ "/api/now-playing" => await HandleNowPlaying(request, ctx, invocation),
+ "/api/top-tracks" => await HandleTopTracks(request, ctx, invocation),
_ => ResponseBuilder.ErrorResponse(404, "Not Found")
};
}
}
catch (Exception exception)
{
- capturedException = exception;
response = ResponseBuilder.ErrorResponse(500, "Internal Server Error");
- StructuredLog.Error(ctx.Logger, "api.request.error", exception, new Dictionary
- {
- ["requestId"] = ctx.AwsRequestId,
- ["method"] = method,
- ["route"] = path,
- ["consumer"] = consumer,
- ["provider"] = provider
- });
- await SentryTelemetry.CaptureExceptionAsync(exception, ctx.Logger, new Dictionary
- {
- ["function"] = ctx.FunctionName,
- ["request_id"] = ctx.AwsRequestId,
- ["route"] = path,
- ["method"] = method,
- ["consumer"] = consumer,
- ["provider"] = provider
- });
+ await invocation.RecordFailureAsync(exception);
}
- stopwatch.Stop();
- transaction?.Finish(response.StatusCode, capturedException);
- await RecordInvocation(ctx, method, path, response.StatusCode, stopwatch.Elapsed.TotalMilliseconds, consumer, provider);
+ await invocation.CompleteAsync(response.StatusCode);
return response;
}
private async Task HandleNowPlaying(
+ APIGatewayHttpApiV2ProxyRequest request,
ILambdaContext context,
- string provider)
+ LambdaInvocation invocation)
{
- var service = new NowPlayingService(_cache, _spotifyApi, _lastFmApi, context.Logger, _runtimeConfig.Features);
- var response = string.Equals(provider, "spotify", StringComparison.OrdinalIgnoreCase)
- ? await service.GetSpotifyNowPlaying()
- : await service.GetNowPlaying();
+ var service = new NowPlayingService(_cache, _spotifyApi, _lastFmApi, context.Logger, _runtimeConfig.Features, _runtimeConfig.Observability);
+ var result = await service.HandleNowPlaying(GetQueryParam(request.RawQueryString, "provider"));
+ invocation.SetProvider(result.Provider);
- return ResponseBuilder.CreateResponse(response, revalidateSeconds: 3);
+ return ResponseBuilder.CreateResponse(result.Response, revalidateSeconds: 3);
}
private async Task HandleTopTracks(
APIGatewayHttpApiV2ProxyRequest request,
- ILambdaContext context)
+ ILambdaContext context,
+ LambdaInvocation invocation)
{
- var timeRange = QueryStringParser.Get(
+ var timeRange = GetQueryParam(
request.RawQueryString,
"time_range",
"medium_term",
["short_term", "medium_term", "long_term"]);
- var rawLimit = QueryStringParser.Get(request.RawQueryString, "limit", "20");
- var limit = int.TryParse(rawLimit, out var parsedLimit) ? Math.Clamp(parsedLimit, 1, 50) : 20;
+ invocation.SetTag(InvocationTags.TimeRange, timeRange);
+ var limit = int.TryParse(GetQueryParam(request.RawQueryString, "limit"), out var parsedLimit) ? parsedLimit : 20;
+
+ var topTracks = await _spotifyApi.GetTopTracks(timeRange, limit);
+ var tracks = topTracks.Items
+ .Select(item => new TopTrackResponseItem(
+ Title: item.Name,
+ Artist: string.Join(", ", item.Artists.Select(artist => artist.Name)),
+ Album: item.Album.Name,
+ AlbumImageUrl: item.Album.Images.FirstOrDefault()?.Url ?? "",
+ SongUrl: item.ExternalUrls.Spotify))
+ .ToArray();
+
+ return ResponseBuilder.CreateResponse(new TopTracksApiResponse(tracks), revalidateSeconds: 300);
+ }
- var response = await new TopTracksService(_spotifyApi, context.Logger).HandleTopTracks(timeRange, limit);
- return ResponseBuilder.CreateResponse(response, revalidateSeconds: 300);
+ private VersionResponse BuildVersionResponse()
+ {
+ return new VersionResponse(
+ Version: _runtimeConfig.Deployment.Version,
+ DeployedAt: _runtimeConfig.Deployment.DeployedAt,
+ DeployedBy: _runtimeConfig.Deployment.DeployedBy,
+ GitSha: _runtimeConfig.Deployment.GitSha);
}
- private static async Task RecordInvocation(
- ILambdaContext context,
- string method,
- string path,
- int statusCode,
- double durationMs,
- string? consumer,
- string? provider)
+ private static string? GetQueryParam(string? rawQueryString, string key)
{
- var outcome = statusCode >= 500 ? "error" : statusCode >= 400 ? "client_error" : "success";
- StructuredLog.Info(context.Logger, "api.request", new Dictionary
+ if (string.IsNullOrEmpty(rawQueryString))
{
- ["requestId"] = context.AwsRequestId,
- ["function"] = context.FunctionName,
- ["method"] = method,
- ["route"] = path,
- ["statusCode"] = statusCode,
- ["durationMs"] = Math.Round(durationMs, 2),
- ["outcome"] = outcome,
- ["consumer"] = consumer,
- ["provider"] = provider
- });
-
- var metric = new InvocationMetric(
- FunctionName: context.FunctionName,
- Operation: "api",
- Route: path,
- Method: method,
- StatusCode: statusCode,
- DurationMs: durationMs,
- Outcome: outcome,
- Consumer: consumer,
- Provider: provider);
-
- await PrometheusMetrics.PushInvocationAsync(metric, context.Logger);
- await SentryTelemetry.RecordInvocationAsync(metric, context.Logger);
+ return null;
+ }
+
+ var value = HttpUtility.ParseQueryString(rawQueryString)[key];
+ return string.IsNullOrEmpty(value) ? null : value;
}
+ private static string GetQueryParam(
+ string? rawQueryString,
+ string key,
+ string defaultValue,
+ IReadOnlyCollection allowedValues)
+ {
+ var value = GetQueryParam(rawQueryString, key);
+ return value is not null && allowedValues.Contains(value) ? value : defaultValue;
+ }
+
+ private static readonly string[] KnownStages = ["/test", "/staging", "/live", "/prod"];
+
private static string NormalisePath(string? rawPath)
{
var path = string.IsNullOrEmpty(rawPath) ? "/" : rawPath;
- var knownStages = new[] { "/test", "/staging", "/live", "/prod" };
- foreach (var stage in knownStages)
+ foreach (var stage in KnownStages)
{
if (path.StartsWith(stage + "/", StringComparison.Ordinal))
{
@@ -233,16 +209,6 @@ _ when path.StartsWith("/", StringComparison.Ordinal) => path,
return null;
}
- private static string? NormaliseConsumer(string? consumer)
- {
- return consumer switch
- {
- "lhowsam-prod" or "lhowsam-dev" or "lhowsam-local" => consumer,
- null or "" => null,
- _ => "unknown"
- };
- }
-
private static bool IsSupportedMethod(string method, string path)
{
if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase))
@@ -255,7 +221,15 @@ private static bool IsSupportedMethod(string method, string path)
private static HttpClient CreateHttpClient(string baseAddress)
{
- return new HttpClient
+ // Recycle pooled connections so a thawed container does not reuse sockets
+ // that went stale while the Lambda was frozen.
+ var handler = new SocketsHttpHandler
+ {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(2),
+ ConnectTimeout = TimeSpan.FromSeconds(2)
+ };
+
+ return new HttpClient(handler)
{
BaseAddress = new Uri(baseAddress),
Timeout = TimeSpan.FromSeconds(10)
diff --git a/src/Lho.Lambda/Models/Api.cs b/src/Lho.Lambda/Models/Api.cs
new file mode 100644
index 0000000..7e7e7ce
--- /dev/null
+++ b/src/Lho.Lambda/Models/Api.cs
@@ -0,0 +1,5 @@
+namespace Lho.Lambda.Models;
+
+public record HealthResponse(string Status);
+
+public record ErrorResponseBody(string Error);
diff --git a/src/Lho.Lambda/Properties/AssemblyInfo.cs b/src/Lho.Lambda/Properties/AssemblyInfo.cs
index ebee51c..d4e0afb 100644
--- a/src/Lho.Lambda/Properties/AssemblyInfo.cs
+++ b/src/Lho.Lambda/Properties/AssemblyInfo.cs
@@ -1,3 +1,5 @@
using Amazon.Lambda.Core;
+using Amazon.Lambda.Serialization.SystemTextJson;
+using Lho.Lambda.Serialization;
-[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
+[assembly: LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer))]
diff --git a/src/Lho.Lambda/Serialization/ApiResponseJsonContext.cs b/src/Lho.Lambda/Serialization/ApiResponseJsonContext.cs
new file mode 100644
index 0000000..072fe50
--- /dev/null
+++ b/src/Lho.Lambda/Serialization/ApiResponseJsonContext.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+using Lho.Lambda.Models;
+
+namespace Lho.Lambda.Serialization;
+
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+[JsonSerializable(typeof(NowPlayingResponse))]
+[JsonSerializable(typeof(TopTracksApiResponse))]
+[JsonSerializable(typeof(VersionResponse))]
+[JsonSerializable(typeof(HealthResponse))]
+[JsonSerializable(typeof(ErrorResponseBody))]
+public partial class ApiResponseJsonContext : JsonSerializerContext;
diff --git a/src/Lho.Lambda/Serialization/ClientJsonContexts.cs b/src/Lho.Lambda/Serialization/ClientJsonContexts.cs
new file mode 100644
index 0000000..9604960
--- /dev/null
+++ b/src/Lho.Lambda/Serialization/ClientJsonContexts.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+using Lho.Lambda.Models;
+
+namespace Lho.Lambda.Serialization;
+
+[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
+[JsonSerializable(typeof(LastFmRecentTracksResponse))]
+public partial class LastFmJsonContext : JsonSerializerContext;
+
+[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
+[JsonSerializable(typeof(SpotifyResponse))]
+[JsonSerializable(typeof(SpotifyTopTracksResponse))]
+[JsonSerializable(typeof(TokenResponse))]
+public partial class SpotifyJsonContext : JsonSerializerContext;
diff --git a/src/Lho.Lambda/Serialization/LambdaEventsJsonContext.cs b/src/Lho.Lambda/Serialization/LambdaEventsJsonContext.cs
new file mode 100644
index 0000000..584a98f
--- /dev/null
+++ b/src/Lho.Lambda/Serialization/LambdaEventsJsonContext.cs
@@ -0,0 +1,8 @@
+using Amazon.Lambda.APIGatewayEvents;
+using System.Text.Json.Serialization;
+
+namespace Lho.Lambda.Serialization;
+
+[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest))]
+[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyResponse))]
+public partial class LambdaEventsJsonContext : JsonSerializerContext;
diff --git a/src/Lho.Lambda/Services/NowPlayingService.cs b/src/Lho.Lambda/Services/NowPlayingService.cs
index dc9569e..767e75a 100644
--- a/src/Lho.Lambda/Services/NowPlayingService.cs
+++ b/src/Lho.Lambda/Services/NowPlayingService.cs
@@ -8,33 +8,36 @@
namespace Lho.Lambda.Services;
+public sealed record NowPlayingResult(NowPlayingResponse Response, string Provider);
+
public class NowPlayingService(
MemoryCache cache,
SpotifyApi spotifyApi,
LastFmApi lastFmApi,
ILambdaLogger logger,
- FeatureFlagsOptions featureFlags)
+ FeatureFlagsOptions featureFlags,
+ ObservabilityOptions observability)
{
private const string LastFmProvider = "lastfm";
private const string SpotifyProvider = "spotify";
private const string LastFmCacheKey = "NowPlaying:lastfm";
private const string SpotifyCacheKey = "NowPlaying:spotify";
- public async Task HandleNowPlaying(string provider = LastFmProvider)
+ public async Task HandleNowPlaying(string? rawProvider)
{
- return string.Equals(provider, SpotifyProvider, StringComparison.OrdinalIgnoreCase)
- ? await GetSpotifyNowPlaying()
- : await GetNowPlaying();
- }
+ var provider = NormaliseProvider(rawProvider);
+ var response = provider == SpotifyProvider
+ ? await GetCachedNowPlaying(SpotifyCacheKey, SpotifyProvider, HandleSpotifyNowPlaying)
+ : await GetCachedNowPlaying(LastFmCacheKey, LastFmProvider, HandleLastFmNowPlaying);
- public async Task GetNowPlaying()
- {
- return await GetCachedNowPlaying(LastFmCacheKey, LastFmProvider, HandleLastFmNowPlaying);
+ return new NowPlayingResult(response, provider);
}
- public async Task GetSpotifyNowPlaying()
+ private static string NormaliseProvider(string? rawProvider)
{
- return await GetCachedNowPlaying(SpotifyCacheKey, SpotifyProvider, HandleSpotifyNowPlaying);
+ return string.Equals(rawProvider, SpotifyProvider, StringComparison.OrdinalIgnoreCase)
+ ? SpotifyProvider
+ : LastFmProvider;
}
private async Task GetCachedNowPlaying(
@@ -53,7 +56,7 @@ private async Task GetCachedNowPlaying(
var response = await fetch();
- if (response.Status == 200 && !string.IsNullOrEmpty(response.Title))
+ if (response.Status == 200)
{
cache.Set(cacheKey, response, TimeSpan.FromSeconds(5));
}
@@ -63,10 +66,10 @@ private async Task GetCachedNowPlaying(
catch (Exception exception)
{
logger.LogLine($"Error fetching now playing data from {provider}: {exception}");
- await SentryTelemetry.CaptureExceptionAsync(exception, logger, new Dictionary
+ await SentryTelemetry.CaptureExceptionAsync(exception, observability, logger, new Dictionary
{
- ["operation"] = "now-playing",
- ["provider"] = provider
+ [InvocationTags.Operation] = "now-playing",
+ [InvocationTags.Provider] = provider
});
return EmptyResponse(status: 500);
}
diff --git a/src/Lho.Lambda/Services/TopTracksService.cs b/src/Lho.Lambda/Services/TopTracksService.cs
deleted file mode 100644
index 4268d1c..0000000
--- a/src/Lho.Lambda/Services/TopTracksService.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using Amazon.Lambda.Core;
-using Lho.Lambda.Clients.Spotify;
-using Lho.Lambda.Models;
-using Lho.Lambda.Observability;
-
-namespace Lho.Lambda.Services;
-
-public class TopTracksService(SpotifyApi spotifyApi, ILambdaLogger logger)
-{
- public async Task HandleTopTracks(string timeRange, int limit)
- {
- try
- {
- var response = await spotifyApi.GetTopTracks(timeRange, limit);
- var tracks = response.Items
- .Select(item => new TopTrackResponseItem(
- Title: item.Name,
- Artist: string.Join(", ", item.Artists.Select(artist => artist.Name)),
- Album: item.Album.Name,
- AlbumImageUrl: item.Album.Images.FirstOrDefault()?.Url ?? "",
- SongUrl: item.ExternalUrls.Spotify))
- .ToArray();
-
- return new TopTracksApiResponse(tracks);
- }
- catch (Exception exception)
- {
- logger.LogLine($"Top tracks fetch failed: {exception}");
- await SentryTelemetry.CaptureExceptionAsync(exception, logger, new Dictionary
- {
- ["operation"] = "top-tracks",
- ["time_range"] = timeRange
- });
- throw;
- }
- }
-}
diff --git a/src/Lho.Lambda/Services/VersionService.cs b/src/Lho.Lambda/Services/VersionService.cs
deleted file mode 100644
index 9fe22b4..0000000
--- a/src/Lho.Lambda/Services/VersionService.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using Lho.Lambda.Models;
-using Lho.Lambda.RuntimeConfiguration.Options;
-
-namespace Lho.Lambda.Services;
-
-public class VersionService(DeploymentOptions deployment)
-{
- public VersionResponse GetVersion()
- {
- return new VersionResponse(
- Version: deployment.Version,
- DeployedAt: deployment.DeployedAt,
- DeployedBy: deployment.DeployedBy,
- GitSha: deployment.GitSha);
- }
-}
diff --git a/src/Lho.Lambda/Utils/QueryStringParser.cs b/src/Lho.Lambda/Utils/QueryStringParser.cs
deleted file mode 100644
index 1e233bf..0000000
--- a/src/Lho.Lambda/Utils/QueryStringParser.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Web;
-
-namespace Lho.Lambda.Utils;
-
-public static class QueryStringParser
-{
- public static string Get(string? rawQueryString, string key, string defaultValue, IReadOnlyCollection? allowedValues = null)
- {
- if (string.IsNullOrEmpty(rawQueryString))
- {
- return defaultValue;
- }
-
- var query = HttpUtility.ParseQueryString(rawQueryString);
- var value = query[key];
- if (string.IsNullOrEmpty(value))
- {
- return defaultValue;
- }
-
- return allowedValues is not null && !allowedValues.Contains(value) ? defaultValue : value;
- }
-}
diff --git a/src/Lho.Lambda/Utils/ResponseBuilder.cs b/src/Lho.Lambda/Utils/ResponseBuilder.cs
index 37cb2bd..e4f4f77 100644
--- a/src/Lho.Lambda/Utils/ResponseBuilder.cs
+++ b/src/Lho.Lambda/Utils/ResponseBuilder.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
-using System.Text.Json.Serialization;
using Amazon.Lambda.APIGatewayEvents;
+using Lho.Lambda.Models;
+using Lho.Lambda.Serialization;
namespace Lho.Lambda.Utils;
@@ -9,7 +10,7 @@ public static class ResponseBuilder
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- DefaultIgnoreCondition = JsonIgnoreCondition.Never
+ TypeInfoResolver = ApiResponseJsonContext.Default
};
private static readonly Dictionary DefaultCorsHeaders = new()
@@ -45,7 +46,7 @@ public static APIGatewayHttpApiV2ProxyResponse ErrorResponse(int statusCode, str
{
StatusCode = statusCode,
Headers = new Dictionary(DefaultCorsHeaders),
- Body = JsonSerializer.Serialize(new { error = message }, JsonOptions)
+ Body = JsonSerializer.Serialize(new ErrorResponseBody(message), JsonOptions)
};
}
}
diff --git a/terraform/authorizer.tf b/terraform/authorizer.tf
index 9fbf411..4d08ef0 100644
--- a/terraform/authorizer.tf
+++ b/terraform/authorizer.tf
@@ -15,7 +15,7 @@ resource "aws_lambda_function" "api_authorizer" {
handler = "Lho.Lambda.Authorizer::Lho.Lambda.Authorizer.Functions.AuthorizerFunction::FunctionHandler"
source_code_hash = data.archive_file.auth_archive.output_base64sha256
runtime = "dotnet10"
- memory_size = 256
+ memory_size = 512
architectures = ["x86_64"]
timeout = 10
@@ -25,19 +25,15 @@ resource "aws_lambda_function" "api_authorizer" {
environment {
variables = {
- API_KEY = var.api_key
- SERVICE_NAME = "now-playing"
- ENVIRONMENT = var.env
- VERSION = var.app_version
- DOTNET_ROLL_FORWARD = "Major"
- GIT_SHA = var.git_sha
- SENTRY_DSN = var.sentry_dsn
- SENTRY_ENVIRONMENT = var.env
- SENTRY_RELEASE = var.app_version
- PUSHGATEWAY_URL = var.pushgateway_url
- PUSHGATEWAY_AUTH_HEADER = var.pushgateway_auth_header
- PROMETHEUS_JOB = "now-playing-authorizer"
- METRICS_ENABLED = tostring(var.monitoring_enabled)
+ API_KEY = var.api_key
+ SERVICE_NAME = "now-playing"
+ ENVIRONMENT = var.env
+ VERSION = var.app_version
+ DOTNET_ROLL_FORWARD = "Major"
+ GIT_SHA = var.git_sha
+ SENTRY_DSN = var.sentry_dsn
+ SENTRY_ENVIRONMENT = var.env
+ SENTRY_RELEASE = var.app_version
}
}
diff --git a/terraform/lambda.tf b/terraform/lambda.tf
index 89049ae..add7b16 100644
--- a/terraform/lambda.tf
+++ b/terraform/lambda.tf
@@ -44,29 +44,25 @@ resource "aws_lambda_function" "lambda" {
}
description = "Now playing Lambda ${var.env}"
- memory_size = 256
+ memory_size = 512
architectures = ["x86_64"]
environment {
variables = {
- SPOTIFY_CLIENT_ID = var.spotify_client_id
- SPOTIFY_CLIENT_SECRET = var.spotify_client_secret
- SPOTIFY_REFRESH_TOKEN = var.spotify_refresh_token
- LASTFM_API_KEY = var.lastfm_api_key
- LASTFM_USERNAME = var.lastfm_username
- SERVICE_NAME = "now-playing"
- ENVIRONMENT = var.env
- VERSION = var.app_version
- DOTNET_ROLL_FORWARD = "Major"
- DEPLOYED_AT = timestamp()
- DEPLOYED_BY = var.deployed_by
- GIT_SHA = var.git_sha
- SENTRY_DSN = var.sentry_dsn
- SENTRY_ENVIRONMENT = var.env
- SENTRY_RELEASE = var.app_version
- PUSHGATEWAY_URL = var.pushgateway_url
- PUSHGATEWAY_AUTH_HEADER = var.pushgateway_auth_header
- PROMETHEUS_JOB = "now-playing"
- METRICS_ENABLED = tostring(var.monitoring_enabled)
+ SPOTIFY_CLIENT_ID = var.spotify_client_id
+ SPOTIFY_CLIENT_SECRET = var.spotify_client_secret
+ SPOTIFY_REFRESH_TOKEN = var.spotify_refresh_token
+ LASTFM_API_KEY = var.lastfm_api_key
+ LASTFM_USERNAME = var.lastfm_username
+ SERVICE_NAME = "now-playing"
+ ENVIRONMENT = var.env
+ VERSION = var.app_version
+ DOTNET_ROLL_FORWARD = "Major"
+ DEPLOYED_AT = timestamp()
+ DEPLOYED_BY = var.deployed_by
+ GIT_SHA = var.git_sha
+ SENTRY_DSN = var.sentry_dsn
+ SENTRY_ENVIRONMENT = var.env
+ SENTRY_RELEASE = var.app_version
}
}
tags = merge(var.tags, {
diff --git a/terraform/variables.tf b/terraform/variables.tf
index bb0ff71..f9dba15 100644
--- a/terraform/variables.tf
+++ b/terraform/variables.tf
@@ -103,25 +103,6 @@ variable "sentry_dsn" {
default = ""
}
-variable "pushgateway_url" {
- type = string
- description = "Prometheus Pushgateway endpoint for Lambda invocation metrics"
- default = "https://pushgateway.lhowsam.com"
-}
-
-variable "pushgateway_auth_header" {
- type = string
- description = "Pushgateway auth header in Header=Value form, for example Authorization=Basic "
- sensitive = true
- default = ""
-}
-
-variable "monitoring_enabled" {
- type = bool
- description = "Whether Lambda functions should push invocation metrics to Pushgateway"
- default = true
-}
-
variable "api_key" {
description = "API key for securing the API Gateway endpoints"
type = string