diff --git a/Directory.Packages.props b/Directory.Packages.props index 3e870319..9b184d3f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + @@ -29,6 +30,8 @@ + + @@ -39,4 +42,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 9288edfd..e87f2584 100644 --- a/README.md +++ b/README.md @@ -79,23 +79,23 @@ public async Task Example() The [`samples/`](./samples) folder contains example applications demonstrating how to use OpenFeature in different .NET scenarios. -| Sample Name | Description | -|---------------------------------------------------|----------------------------------------------------------------| -| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. | +| Sample Name | Description | +| ------------------------------------------- | ----------------------------------------- | +| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. | **Getting Started with a Sample:** 1. Navigate to the sample directory - ```shell - cd samples/AspNetCore - ``` + ```shell + cd samples/AspNetCore + ``` 2. Restore dependencies and run - ```shell - dotnet run - ``` + ```shell + dotnet run + ``` Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide! @@ -534,6 +534,135 @@ services.AddOpenFeature(builder => }); ``` +### Trace Enricher Hook + +The `TraceEnricherHook` enriches telemetry traces with additional information during the feature flag evaluation lifecycle. This hook adds relevant flag evaluation details as tags and events to the current `Activity` for tracing purposes. + +For this hook to function correctly, an active span must be set in the current `Activity`, otherwise the hook will no-op. + +Below are the tags added to the trace event: + +| Tag Name | Description | Source | +| --------------------------- | ---------------------------------------------------------------------------- | ----------------------------- | +| feature_flag.key | The lookup key of the feature flag | Hook context flag key | +| feature_flag.provider.name | The name of the feature flag provider | Provider metadata | +| feature_flag.result.reason | The reason code which shows how a feature flag value was determined | Evaluation details | +| feature_flag.result.variant | A semantic identifier for an evaluated flag value | Evaluation details | +| feature_flag.result.value | The evaluated value of the feature flag | Evaluation details | +| feature_flag.context.id | The unique identifier for the flag evaluation context | Flag metadata (if available) | +| feature_flag.set.id | The identifier of the flag set to which the feature flag belongs | Flag metadata (if available) | +| feature_flag.version | The version of the ruleset used during the evaluation | Flag metadata (if available) | +| error.type | Describes a class of error the operation ended with | Evaluation details (if error) | +| error.message | A message explaining the nature of an error occurring during flag evaluation | Evaluation details (if error) | + +#### Example + +The following example demonstrates the use of the `TraceEnricherHook` with the `OpenFeature dotnet-sdk`. The traces are sent to a `jaeger` OTLP collector running at `localhost:4317`. + +```csharp +using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature.Hooks; +using OpenTelemetry.Exporter; +using OpenTelemetry.Resources; +using OpenTelemetry; +using OpenTelemetry.Trace; + +namespace OpenFeatureTestApp +{ + class Hello { + static void Main(string[] args) { + + // set up the OpenTelemetry OTLP exporter + var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("my-tracer") + .ConfigureResource(r => r.AddService("jaeger-test")) + .AddOtlpExporter(o => + { + o.ExportProcessorType = ExportProcessorType.Simple; + }) + .Build(); + + // add the TraceEnricherHook to the OpenFeature instance + OpenFeature.Api.Instance.AddHooks(new TraceEnricherHook()); + + var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013")); + + // Set the flagdProvider as the provider for the OpenFeature SDK + OpenFeature.Api.Instance.SetProvider(flagdProvider); + + var client = OpenFeature.Api.Instance.GetClient("my-app"); + + var val = client.GetBooleanValueAsync("myBoolFlag", false, null); + + // Print the value of the 'myBoolFlag' feature flag + System.Console.WriteLine(val.Result.ToString()); + } + } +} +``` + +After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI. + +### Metrics Hook + +For this hook to function correctly a global `MeterProvider` must be set. +`MetricsHook` performs metric collection by tapping into various hook stages. + +Below are the metrics extracted by this hook and dimensions they carry: + +| Metric key | Description | Unit | Dimensions | +| -------------------------------------- | ------------------------------- | ------------ | ----------------------------- | +| feature_flag.evaluation_requests_total | Number of evaluation requests | {request} | key, provider name | +| feature_flag.evaluation_success_total | Flag evaluation successes | {impression} | key, provider name, reason | +| feature_flag.evaluation_error_total | Flag evaluation errors | 1 | key, provider name, exception | +| feature_flag.evaluation_active_count | Active flag evaluations counter | 1 | key, provider name | + +Consider the following code example for usage. + +#### Example + +The following example demonstrates the use of the `MetricsHook` with the `OpenFeature dotnet-sdk`. The metrics are sent to the `console`. + +```csharp +using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature; +using OpenFeature.Hooks; +using OpenTelemetry; +using OpenTelemetry.Metrics; + +namespace OpenFeatureTestApp +{ + class Hello { + static void Main(string[] args) { + + // set up the OpenTelemetry OTLP exporter + var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter("OpenFeature") + .ConfigureResource(r => r.AddService("openfeature-test")) + .AddConsoleExporter() + .Build(); + + // add the MetricsHook to the OpenFeature instance + OpenFeature.Api.Instance.AddHooks(new MetricsHook()); + + var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013")); + + // Set the flagdProvider as the provider for the OpenFeature SDK + OpenFeature.Api.Instance.SetProvider(flagdProvider); + + var client = OpenFeature.Api.Instance.GetClient("my-app"); + + var val = client.GetBooleanValueAsync("myBoolFlag", false, null); + + // Print the value of the 'myBoolFlag' feature flag + System.Console.WriteLine(val.Result.ToString()); + } + } +} +``` + +After running this example, you should be able to see some metrics being generated into the console. + ## ⭐️ Support the project diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index a9c2cd50..5f4f0146 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -3,16 +3,33 @@ using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; using OpenFeature.Providers.Memory; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddProblemDetails(); +// Configure OpenTelemetry +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("openfeature-aspnetcore-sample")) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddOtlpExporter()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddMeter("OpenFeature") + .AddOtlpExporter()); + builder.Services.AddOpenFeature(featureBuilder => { featureBuilder.AddHostedFeatureLifecycle() .AddHook(sp => new LoggingHook(sp.GetRequiredService>())) + .AddHook() + .AddHook() .AddInMemoryProvider("InMemory", _ => new Dictionary() { { diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index 01e452d7..3dd554a7 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -1,9 +1,19 @@  + + false + + + + + + + + diff --git a/src/OpenFeature/Hooks/MetricsConstants.cs b/src/OpenFeature/Hooks/MetricsConstants.cs new file mode 100644 index 00000000..e54dd61c --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsConstants.cs @@ -0,0 +1,16 @@ +namespace OpenFeature.Hooks; + +internal static class MetricsConstants +{ + internal const string ActiveCountName = "feature_flag.evaluation_active_count"; + internal const string RequestsTotalName = "feature_flag.evaluation_requests_total"; + internal const string SuccessTotalName = "feature_flag.evaluation_success_total"; + internal const string ErrorTotalName = "feature_flag.evaluation_error_total"; + + internal const string ActiveDescription = "active flag evaluations counter"; + internal const string RequestsDescription = "feature flag evaluation request counter"; + internal const string SuccessDescription = "feature flag evaluation success counter"; + internal const string ErrorDescription = "feature flag evaluation error counter"; + + internal const string ExceptionAttr = "exception"; +} diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs new file mode 100644 index 00000000..2f2314f0 --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -0,0 +1,100 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Telemetry; + +namespace OpenFeature.Hooks; + +/// +/// Represents a hook for capturing metrics related to flag evaluations. +/// The meter instrumentation name is "OpenFeature". +/// +/// This is still experimental and subject to change. +public class MetricsHook : Hook +{ + private static readonly AssemblyName AssemblyName = typeof(MetricsHook).Assembly.GetName(); + private static readonly string InstrumentationName = AssemblyName.Name ?? "OpenFeature"; + private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0"; + private static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion); + + private readonly UpDownCounter _evaluationActiveUpDownCounter; + private readonly Counter _evaluationRequestCounter; + private readonly Counter _evaluationSuccessCounter; + private readonly Counter _evaluationErrorCounter; + + /// + /// Initializes a new instance of the class. + /// + public MetricsHook() + { + this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription); + this._evaluationRequestCounter = Meter.CreateCounter(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription); + this._evaluationSuccessCounter = Meter.CreateCounter(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription); + this._evaluationErrorCounter = Meter.CreateCounter(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription); + } + + /// + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { TelemetryConstants.Key, context.FlagKey }, + { TelemetryConstants.Provider, context.ProviderMetadata.Name } + }; + + this._evaluationActiveUpDownCounter.Add(1, tagList); + this._evaluationRequestCounter.Add(1, tagList); + + return base.BeforeAsync(context, hints, cancellationToken); + } + + + /// + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { TelemetryConstants.Key, context.FlagKey }, + { TelemetryConstants.Provider, context.ProviderMetadata.Name }, + { TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() } + }; + + this._evaluationSuccessCounter.Add(1, tagList); + + return base.AfterAsync(context, details, hints, cancellationToken); + } + + /// + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { TelemetryConstants.Key, context.FlagKey }, + { TelemetryConstants.Provider, context.ProviderMetadata.Name }, + { MetricsConstants.ExceptionAttr, error.Message } + }; + + this._evaluationErrorCounter.Add(1, tagList); + + return base.ErrorAsync(context, error, hints, cancellationToken); + } + + /// + public override ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { TelemetryConstants.Key, context.FlagKey }, + { TelemetryConstants.Provider, context.ProviderMetadata.Name } + }; + + this._evaluationActiveUpDownCounter.Add(-1, tagList); + + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } +} diff --git a/src/OpenFeature/Hooks/TraceEnricherHook.cs b/src/OpenFeature/Hooks/TraceEnricherHook.cs new file mode 100644 index 00000000..08914b1c --- /dev/null +++ b/src/OpenFeature/Hooks/TraceEnricherHook.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; +using OpenFeature.Model; +using OpenFeature.Telemetry; + +namespace OpenFeature.Hooks; + +/// +/// A hook that enriches telemetry traces with additional information during the feature flag evaluation lifecycle. +/// This hook adds relevant flag evaluation details as tags and events to the current for tracing purposes. +/// On error, it attaches exception information to the trace, using the appropriate API depending on the .NET version. +/// +/// This is still experimental and subject to change. +public class TraceEnricherHook : Hook +{ + /// + /// Adds tags and events to the current for tracing purposes. + /// + /// The type of the flag value being evaluated. + /// The hook context containing metadata about the evaluation. + /// Details about the flag evaluation including the key, value, and variant. + /// Optional dictionary of hints that can modify hook behavior. + /// A token to cancel the operation. + /// A completed representing the asynchronous operation. + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var evaluationEvent = EvaluationEventBuilder.Build(context, details); + + var tags = new ActivityTagsCollection(); + foreach (var kvp in evaluationEvent.Attributes) + { + tags[kvp.Key] = kvp.Value; + } + + Activity.Current?.AddEvent(new ActivityEvent(evaluationEvent.Name, tags: tags)); + + return base.FinallyAsync(context, details, hints, cancellationToken); + } +} diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index c47b109d..3d81a99e 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -9,6 +9,7 @@ + diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs new file mode 100644 index 00000000..54f6e19c --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs @@ -0,0 +1,141 @@ +using OpenFeature.Hooks; +using OpenFeature.Model; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; + +namespace OpenFeature.Tests.Hooks; + +[CollectionDefinition(nameof(MetricsHookTest), DisableParallelization = true)] +public class MetricsHookTest : IDisposable +{ + private readonly List _exportedItems; + private readonly MeterProvider _meterProvider; + + public MetricsHookTest() + { + // Arrange metrics collector + this._exportedItems = []; + this._meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter("OpenFeature") + .ConfigureResource(r => r.AddService("open-feature")) + .AddInMemoryExporter(this._exportedItems, + option => option.PeriodicExportingMetricReaderOptions = + new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 }) + .Build(); + } + +#pragma warning disable CA1816 + public void Dispose() + { + this._meterProvider.Shutdown(); + } +#pragma warning restore CA1816 + + [Fact] + public async Task After_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_success_total"; + var metricsHook = new MetricsHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Error_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_error_total"; + var metricsHook = new MetricsHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.ErrorAsync(ctx, new Exception(), new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Finally_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_active_count"; + var metricsHook = new MetricsHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"); + + // Act + await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric feature_flag.evaluation_success_total is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Before_Test() + { + // Arrange + const string metricName1 = "feature_flag.evaluation_active_count"; + const string metricName2 = "feature_flag.evaluation_requests_total"; + var metricsHook = new MetricsHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric1 = this._exportedItems.FirstOrDefault(m => m.Name == metricName1); + Assert.NotNull(metric1); + + var metric2 = this._exportedItems.FirstOrDefault(m => m.Name == metricName2); + Assert.NotNull(metric2); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2); + Assert.True(noOtherMetric); + } +} diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs new file mode 100644 index 00000000..f73d3620 --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using OpenFeature.Hooks; +using OpenFeature.Model; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace OpenFeature.Tests.Hooks; + +[CollectionDefinition(nameof(TraceEnricherHookTests), DisableParallelization = true)] +public class TraceEnricherHookTests : IDisposable +{ + private readonly List _exportedItems; + private readonly TracerProvider _tracerProvider; + private readonly Tracer _tracer; + + public TraceEnricherHookTests() + { + // List that will be populated with the traces by InMemoryExporter + this._exportedItems = []; + + // Create a new in-memory exporter + this._tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("my-tracer") + .ConfigureResource(r => r.AddService("in-memory-test")) + .AddInMemoryExporter(this._exportedItems) + .Build(); + + this._tracer = this._tracerProvider.GetTracer("my-tracer"); + } + +#pragma warning disable CA1816 + public void Dispose() + { + this._tracerProvider.Shutdown(); + } +#pragma warning restore CA1816 + + [Fact] + public async Task TestFinally() + { + // Arrange + var traceEnricherHook = new TraceEnricherHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + var span = this._tracer.StartActiveSpan("my-span"); + await traceEnricherHook.FinallyAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + span.End(); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Single(this._exportedItems); + var rootSpan = this._exportedItems.First(); + + Assert.Single(rootSpan.Events); + ActivityEvent ev = rootSpan.Events.First(); + Assert.Equal("feature_flag.evaluation", ev.Name); + + Assert.Contains(new KeyValuePair("feature_flag.key", "my-flag"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.result.variant", "default"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.provider.name", "my-provider"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.result.reason", "static"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.result.value", "foo"), ev.Tags); + } + + [Fact] + public async Task TestFinally_NoSpan() + { + // Arrange + var traceEnricherHook = new TraceEnricherHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await traceEnricherHook.FinallyAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Empty(this._exportedItems); + } +} diff --git a/test/OpenFeature.Tests/OpenFeature.Tests.csproj b/test/OpenFeature.Tests/OpenFeature.Tests.csproj index a556655a..8abb4891 100644 --- a/test/OpenFeature.Tests/OpenFeature.Tests.csproj +++ b/test/OpenFeature.Tests/OpenFeature.Tests.csproj @@ -19,6 +19,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive