Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement scope configuration #1353

Merged
merged 39 commits into from
Aug 18, 2024
Merged

Conversation

brettmc
Copy link
Collaborator

@brettmc brettmc commented Jul 22, 2024

Implement the "scope config" changes specified in open-telemetry/opentelemetry-specification#3877

  • Tracer/Meter/LoggerProviders can accept a Configurator, which operates on InstrumentationScope. The initial use-case is to enable/disable telemetry generation based on instrumentation scope (eg "disable tracing for all tracers named X")
  • Implement Condition and some obvious Predicates to allow controlling the configuration
  • Create a WeakMap from Providers to the instances they create, so that updating the config for a provider can be propagated down to the instances, allowing for run-time reconfiguration of signals
  • Link Instruments back to the Meter which provided them, so that disabling a meter can stop async and sync instruments from generating metrics
  • rename enabled to isEnabled, since the spec didn't actually specify what the methods had to be called, and isEnabled seems nicer

Closes: #1349

@brettmc brettmc requested a review from a team July 22, 2024 04:07
Copy link

codecov bot commented Jul 22, 2024

Codecov Report

Attention: Patch coverage is 60.75949% with 62 lines in your changes missing coverage. Please review.

Project coverage is 74.01%. Comparing base (d2ec5e6) to head (b2f89b6).
Report is 2 commits behind head on main.

Files Patch % Lines
...c/SDK/Common/InstrumentationScope/Configurator.php 66.66% 9 Missing ⚠️
src/SDK/Metrics/MeterProviderBuilder.php 0.00% 8 Missing ⚠️
...ommon/InstrumentationScope/ConfiguratorClosure.php 0.00% 5 Missing ⚠️
src/SDK/Trace/TracerProviderBuilder.php 0.00% 4 Missing ⚠️
src/SDK/Logs/LoggerProviderBuilder.php 40.00% 3 Missing ⚠️
src/API/Metrics/LateBindingMeter.php 0.00% 2 Missing ⚠️
src/API/Metrics/Noop/NoopGauge.php 0.00% 2 Missing ⚠️
src/API/Metrics/Noop/NoopMeter.php 0.00% 2 Missing ⚠️
src/SDK/Logs/Logger.php 71.42% 2 Missing ⚠️
src/SDK/Logs/LoggerConfig.php 0.00% 2 Missing ⚠️
... and 17 more
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##               main    #1353      +/-   ##
============================================
- Coverage     74.19%   74.01%   -0.19%     
- Complexity     2584     2641      +57     
============================================
  Files           373      379       +6     
  Lines          7430     7547     +117     
============================================
+ Hits           5513     5586      +73     
- Misses         1917     1961      +44     
Flag Coverage Δ
8.1 73.67% <60.75%> (-0.17%) ⬇️
8.2 73.77% <60.75%> (-0.33%) ⬇️
8.3 73.88% <60.75%> (-0.17%) ⬇️
8.4 73.88% <60.75%> (-0.17%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files Coverage Δ
src/API/Logs/NoopLogger.php 100.00% <100.00%> (ø)
src/API/Trace/NoopTracer.php 100.00% <100.00%> (ø)
...rc/SDK/Common/InstrumentationScope/ConfigTrait.php 100.00% <100.00%> (ø)
src/SDK/Logs/LoggerProvider.php 100.00% <100.00%> (+12.50%) ⬆️
src/SDK/Metrics/Meter.php 76.81% <100.00%> (+0.69%) ⬆️
src/SDK/Metrics/MeterProvider.php 92.00% <100.00%> (+6.28%) ⬆️
src/SDK/Metrics/MetricReader/ExportingReader.php 73.84% <100.00%> (+0.40%) ⬆️
src/SDK/Metrics/ObservableInstrumentTrait.php 83.33% <100.00%> (-6.67%) ⬇️
src/SDK/Metrics/SynchronousInstrumentTrait.php 88.88% <100.00%> (+3.17%) ⬆️
src/SDK/Trace/TracerProvider.php 100.00% <100.00%> (+6.66%) ⬆️
... and 27 more

... and 2 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update d2ec5e6...b2f89b6. Read the comment docs.

src/SDK/Logs/LoggerProvider.php Outdated Show resolved Hide resolved
@@ -143,6 +148,12 @@ public function collectAndPush(iterable $streamIds): void
$aggregator = $this->asynchronousAggregatorFactories[$streamId]->create();

$instrumentId = $this->streamToInstrument[$streamId];
if (
array_key_exists($instrumentId, $this->instruments)
&& $this->instruments[$instrumentId]->meter?->isEnabled() === false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be moved into MetricProducer (/MetricReader) if we don't want to export synchronous metrics of disabled meters.

We could also remove the underlying metric streams but this would require more effort.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adjusted MetricReader to not export when source->instrument->meter is not enabled. I've left the above code in place for now, since it also acts to stop observer callbacks from being called (which seems like a performance win, if those callbacks were computationally expensive) - happy to revisit that, though!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since it also acts to stop observer callbacks from being called

We should also skip collecting synchronous aggregators which could be done by moving the $this->instruments[$instrumentId]->meter->isEnabled() check to the beginning of the for-loop and populating $this->instruments in ::registerSynchronousStream()/::registerAsynchronousStream() instead of ::registerCallback() (could alternatively be done in ExportingReader::doCollect() by removing disabled meters from the stream ids that are passed to ::collectAndPush(), but keeping it in MetricRegistry seems easier with the current setup). But...

We could also remove the underlying metric streams but this would require more effort.

This seems like the only way to implement it properly, otherwise we will export incorrect data when instrumentation scopes are reenabled.

# w/ temporality=cumulative
$c = $meterProvider->getMeter('test')->createCounter('c');

# t0
$meterConfig->setDisabled(false);
$c->add(1);

# t1
$meterConfig->setDisabled(true);
$c->add(1);

# t2, {sum=1, startTimestamp=t2}; must not export {sum=2, startTimestamp=t0}
$meterConfig->setDisabled(false);
$c->add(1);
$meterProvider->forceFlush();

Very rough implementation idea (alternatively see implementation with slightly different metrics SDK):

  • on disable: remove streams for all instrumentation scope instruments (within Meter: $this->instruments->writers and ->observers); logic similar to stalenesshandler callbacks in Meter and ExportingReader which already remove streams
  • on enable: recreate streams for all instrumentation scope instruments by calling MetricFactory::createSynchronousWriter()/::createAsynchronousObserver()
  • Meter::createSynchronousWriter()/::createAsynchronousObserver() must not create streams if the instrumentation scope is disabled

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't been able to get this to work, but have left a todo and a skipped integration.

tests/Benchmark/MetricBench.php Outdated Show resolved Hide resolved
src/SDK/Common/InstrumentationScope/Predicate/Name.php Outdated Show resolved Hide resolved
{
foreach ($this->conditions as $condition) {
if ($condition->matches($scope)) {
return new Config($condition->state());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will we add new configuration options if the spec is extended (esp. if signal specific options are added; do we want a dedicated config class per signal?)?


I'm currently playing around with an alternative approach that uses callbacks to modify the config rather than using the config state that is returned by the first matching condition; all matching callbacks are applied to the initial config, which would make supporting new config options trivial. The configurator uses a WeakMap<InstrumentationScope, TConfig> to update configs when a new callback is registered.

(new Configurator(static fn() => new MeterConfig()))
    ->with(static fn(MeterConfig $config) => $config->disabled = true, name: 'io.opentelemetry.contrib.*')
    ->with(static fn(MeterConfig $config) => $config->disabled = true, name: 'my-meter')

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we do, or will, need a config class per signal. I was just trying to avoid copy-pasting the same code when they currently have the same functionality.
I don't mind addressing that sooner than later - either I do just turn the one general class into 3 signal-specific ones, or I'm happy to incorporate your alternative if you've got some more code to point me at?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was using the following Configurator implementation, with TracerProvider/MeterProvider/LoggerProvider accepting a Closure(InstrumentationScope): TConfig as configurator ("[Tracer/Meter/Logger]Configurator is modeled as a function to maximize flexibility.").

Configurator implementation

I'm not too happy with the state of this implementation as it does not support removal of callbacks yet.
Might want to add a predicate parameter to ::with() that allows flexible conditions.

/**
 * @template T
 */
final class Configurator {

    /** @var Closure(InstrumentationScope): T */
    private readonly Closure $factory;
    /** @var WeakMap<InstrumentationScope, T> */
    private WeakMap $configs;
    /** @var list<ConfiguratorClosure> */
    private array $configurators = [];

    /**
     * @param Closure(InstrumentationScope): T $factory
     */
    public function __construct(Closure $factory) {
        $this->configs = new WeakMap();
        $this->factory = $factory;
    }

    /**
     * @param Closure(T, InstrumentationScope): void $closure
     */
    public function with(Closure $closure, ?string $name, ?string $version = null, ?string $schemaUrl = null): self {
        $this->configurators[] = $configurator = new ConfiguratorClosure($closure, self::namePattern($name), $version, $schemaUrl);

        foreach ($this->configs as $instrumentationScope => $config) {
            if ($configurator->matches($instrumentationScope)) {
                ($configurator->closure)($config, $instrumentationScope);
            }
        }

        return $this;
    }

    /**
     * @return T
     */
    public function resolve(InstrumentationScope $instrumentationScope): object {
        if ($config = $this->configs[$instrumentationScope] ?? null) {
            return $config;
        }

        $config = ($this->factory)($instrumentationScope);
        foreach ($this->configurators as $configurator) {
            if ($configurator->matches($instrumentationScope)) {
                ($configurator->closure)($config, $instrumentationScope);
            }
        }

        return $this->configs[$instrumentationScope] ??= $config;
    }

    private static function namePattern(?string $name): ?string {
        return $name !== null
            ? sprintf('/^%s$/', strtr(preg_quote($name, '/'), ['\\?' => '.', '\\*' => '.*']))
            : null;
    }
}

/**
 * @internal
 */
final class ConfiguratorClosure {

    public function __construct(
        public readonly Closure $closure,
        private readonly ?string $name,
        private readonly ?string $version,
        private readonly ?string $schemaUrl,
    ) {}

    public function matches(InstrumentationScope $instrumentationScope): bool {
        return ($this->name === null || preg_match($this->name, $instrumentationScope->name))
            && ($this->version === null || $this->version === $instrumentationScope->version)
            && ($this->schemaUrl === null || $this->schemaUrl === $instrumentationScope->schemaUrl);
    }
}
$meterProviderBuilder->setMeterConfigurator((new Configurator(static fn() => new MeterConfig()))
    ->with(static fn(MeterConfig $config) => $config->setDisabled(true), name: '*')
    ->with(static fn(MeterConfig $config) => $config->setDisabled(false), name: 'test')
    ->resolve(...)):

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the Configurator implementation to use the above. I had to suppress a couple of phan/phpstan complaints about templates, but it works. I added a couple of convenience methods to create a default Configurator for each signal (Configurator::meter() etc).
I think it's also worth noting/documenting that if there are multiple matching rules in a configurator, then the last one wins.

@brettmc
Copy link
Collaborator Author

brettmc commented Aug 13, 2024

@open-telemetry/php-approvers I think this is ready for review now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Implement scope configuration
3 participants