diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index c2aee5e3..7fe22f16 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -22,6 +22,7 @@ jobs: project: [ 'Aws', 'Context/Swoole', + 'Exporter/Instana', 'Instrumentation/AwsSdk', 'Instrumentation/CakePHP', 'Instrumentation/CodeIgniter', diff --git a/.gitsplit.yml b/.gitsplit.yml index 6fc01045..b357f413 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -10,6 +10,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/opentelemetry-meta.git" - prefix: "src/Aws" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-aws.git" + - prefix: "src/Exporter/Instana" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-exporter-instana.git" - prefix: "src/Symfony" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-sdk-bundle.git" - prefix: "src/Instrumentation/CodeIgniter" diff --git a/composer.json b/composer.json index 54413b8b..f61b34ff 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "psr-4": { "OpenTelemetry\\Contrib\\Aws\\": "src/Aws/src", "OpenTelemetry\\Contrib\\Context\\Swoole\\": "src/Context/Swoole/src", + "OpenTelemetry\\Contrib\\Exporter\\Instana\\": "src/Exporter/Instana/src", "OpenTelemetry\\Contrib\\Instrumentation\\CakePHP\\": "src/Instrumentation/CakePHP/src", "OpenTelemetry\\Contrib\\Instrumentation\\CodeIgniter\\": "src/Instrumentation/CodeIgniter/src", "OpenTelemetry\\Contrib\\Instrumentation\\Curl\\": "src/Instrumentation/Curl/src", @@ -93,6 +94,7 @@ "open-telemetry/opentelemetry-auto-codeigniter": "self.version", "open-telemetry/opentelemetry-auto-curl": "self.version", "open-telemetry/opentelemetry-auto-ext-amqp": "self.version", + "open-telemetry/opentelemetry-exporter-instana": "self.version", "open-telemetry/opentelemetry-auto-ext-rdkafka": "self.version", "open-telemetry/opentelemetry-auto-guzzle": "self.version", "open-telemetry/opentelemetry-auto-http-async": "self.version", diff --git a/src/Exporter/Instana/.gitattributes b/src/Exporter/Instana/.gitattributes new file mode 100644 index 00000000..04646434 --- /dev/null +++ b/src/Exporter/Instana/.gitattributes @@ -0,0 +1,13 @@ +* text=auto + +*.md diff=markdown +*.php diff=php + +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/tests export-ignore +/.phan/ export-ignore diff --git a/src/Exporter/Instana/.gitignore b/src/Exporter/Instana/.gitignore new file mode 100644 index 00000000..57872d0f --- /dev/null +++ b/src/Exporter/Instana/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/src/Exporter/Instana/.phan/config.php b/src/Exporter/Instana/.phan/config.php new file mode 100644 index 00000000..6473a9aa --- /dev/null +++ b/src/Exporter/Instana/.phan/config.php @@ -0,0 +1,371 @@ + '8.1', + + // If enabled, missing properties will be created when + // they are first seen. If false, we'll report an + // error message if there is an attempt to write + // to a class property that wasn't explicitly + // defined. + 'allow_missing_properties' => false, + + // If enabled, null can be cast to any type and any + // type can be cast to null. Setting this to true + // will cut down on false positives. + 'null_casts_as_any_type' => false, + + // If enabled, allow null to be cast as any array-like type. + // + // This is an incremental step in migrating away from `null_casts_as_any_type`. + // If `null_casts_as_any_type` is true, this has no effect. + 'null_casts_as_array' => true, + + // If enabled, allow any array-like type to be cast to null. + // This is an incremental step in migrating away from `null_casts_as_any_type`. + // If `null_casts_as_any_type` is true, this has no effect. + 'array_casts_as_null' => true, + + // If enabled, scalars (int, float, bool, string, null) + // are treated as if they can cast to each other. + // This does not affect checks of array keys. See `scalar_array_key_cast`. + 'scalar_implicit_cast' => false, + + // If enabled, any scalar array keys (int, string) + // are treated as if they can cast to each other. + // E.g. `array` can cast to `array` and vice versa. + // Normally, a scalar type such as int could only cast to/from int and mixed. + 'scalar_array_key_cast' => true, + + // If this has entries, scalars (int, float, bool, string, null) + // are allowed to perform the casts listed. + // + // E.g. `['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']]` + // allows casting null to a string, but not vice versa. + // (subset of `scalar_implicit_cast`) + 'scalar_implicit_partial' => [], + + // If enabled, Phan will warn if **any** type in a method invocation's object + // is definitely not an object, + // or if **any** type in an invoked expression is not a callable. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_method_checking' => false, + + // If enabled, Phan will warn if **any** type of the object expression for a property access + // does not contain that property. + 'strict_object_checking' => false, + + // If enabled, Phan will warn if **any** type in the argument's union type + // cannot be cast to a type in the parameter's expected union type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_param_checking' => false, + + // If enabled, Phan will warn if **any** type in a property assignment's union type + // cannot be cast to a type in the property's declared union type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_property_checking' => false, + + // If enabled, Phan will warn if **any** type in a returned value's union type + // cannot be cast to the declared return type. + // Setting this to true will introduce numerous false positives + // (and reveal some bugs). + 'strict_return_checking' => false, + + // If true, seemingly undeclared variables in the global + // scope will be ignored. + // + // This is useful for projects with complicated cross-file + // globals that you have no hope of fixing. + 'ignore_undeclared_variables_in_global_scope' => true, + + // Set this to false to emit `PhanUndeclaredFunction` issues for internal functions that Phan has signatures for, + // but aren't available in the codebase, or from Reflection. + // (may lead to false positives if an extension isn't loaded) + // + // If this is true(default), then Phan will not warn. + // + // Even when this is false, Phan will still infer return values and check parameters of internal functions + // if Phan has the signatures. + 'ignore_undeclared_functions_with_known_signatures' => true, + + // Backwards Compatibility Checking. This is slow + // and expensive, but you should consider running + // it before upgrading your version of PHP to a + // new version that has backward compatibility + // breaks. + // + // If you are migrating from PHP 5 to PHP 7, + // you should also look into using + // [php7cc (no longer maintained)](https://github.com/sstalle/php7cc) + // and [php7mar](https://github.com/Alexia/php7mar), + // which have different backwards compatibility checks. + 'backward_compatibility_checks' => false, + + // If true, check to make sure the return type declared + // in the doc-block (if any) matches the return type + // declared in the method signature. + 'check_docblock_signature_return_type_match' => false, + + // If true, make narrowed types from phpdoc params override + // the real types from the signature, when real types exist. + // (E.g. allows specifying desired lists of subclasses, + // or to indicate a preference for non-nullable types over nullable types) + // + // Affects analysis of the body of the method and the param types passed in by callers. + // + // (*Requires `check_docblock_signature_param_type_match` to be true*) + 'prefer_narrowed_phpdoc_param_type' => true, + + // (*Requires `check_docblock_signature_return_type_match` to be true*) + // + // If true, make narrowed types from phpdoc returns override + // the real types from the signature, when real types exist. + // + // (E.g. allows specifying desired lists of subclasses, + // or to indicate a preference for non-nullable types over nullable types) + // + // This setting affects the analysis of return statements in the body of the method and the return types passed in by callers. + 'prefer_narrowed_phpdoc_return_type' => true, + + // If enabled, check all methods that override a + // parent method to make sure its signature is + // compatible with the parent's. + // + // This check can add quite a bit of time to the analysis. + // + // This will also check if final methods are overridden, etc. + 'analyze_signature_compatibility' => true, + + // This setting maps case-insensitive strings to union types. + // + // This is useful if a project uses phpdoc that differs from the phpdoc2 standard. + // + // If the corresponding value is the empty string, + // then Phan will ignore that union type (E.g. can ignore 'the' in `@return the value`) + // + // If the corresponding value is not empty, + // then Phan will act as though it saw the corresponding UnionTypes(s) + // when the keys show up in a UnionType of `@param`, `@return`, `@var`, `@property`, etc. + // + // This matches the **entire string**, not parts of the string. + // (E.g. `@return the|null` will still look for a class with the name `the`, but `@return the` will be ignored with the below setting) + // + // (These are not aliases, this setting is ignored outside of doc comments). + // (Phan does not check if classes with these names exist) + // + // Example setting: `['unknown' => '', 'number' => 'int|float', 'char' => 'string', 'long' => 'int', 'the' => '']` + 'phpdoc_type_mapping' => [], + + // Set to true in order to attempt to detect dead + // (unreferenced) code. Keep in mind that the + // results will only be a guess given that classes, + // properties, constants and methods can be referenced + // as variables (like `$class->$property` or + // `$class->$method()`) in ways that we're unable + // to make sense of. + 'dead_code_detection' => false, + + // Set to true in order to attempt to detect unused variables. + // `dead_code_detection` will also enable unused variable detection. + // + // This has a few known false positives, e.g. for loops or branches. + 'unused_variable_detection' => false, + + // Set to true in order to attempt to detect redundant and impossible conditions. + // + // This has some false positives involving loops, + // variables set in branches of loops, and global variables. + 'redundant_condition_detection' => false, + + // If enabled, Phan will act as though it's certain of real return types of a subset of internal functions, + // even if those return types aren't available in reflection (real types were taken from php 7.3 or 8.0-dev, depending on target_php_version). + // + // Note that with php 7 and earlier, php would return null or false for many internal functions if the argument types or counts were incorrect. + // As a result, enabling this setting with target_php_version 8.0 may result in false positives for `--redundant-condition-detection` when codebases also support php 7.x. + 'assume_real_types_for_internal_functions' => false, + + // If true, this runs a quick version of checks that takes less + // time at the cost of not running as thorough + // of an analysis. You should consider setting this + // to true only when you wish you had more **undiagnosed** issues + // to fix in your code base. + // + // In quick-mode the scanner doesn't rescan a function + // or a method's code block every time a call is seen. + // This means that the problem here won't be detected: + // + // ```php + // false, + + // Enable or disable support for generic templated + // class types. + 'generic_types_enabled' => true, + + // Override to hardcode existence and types of (non-builtin) globals in the global scope. + // Class names should be prefixed with `\`. + // + // (E.g. `['_FOO' => '\FooClass', 'page' => '\PageClass', 'userId' => 'int']`) + 'globals_type_map' => [], + + // The minimum severity level to report on. This can be + // set to `Issue::SEVERITY_LOW`, `Issue::SEVERITY_NORMAL` or + // `Issue::SEVERITY_CRITICAL`. Setting it to only + // critical issues is a good place to start on a big + // sloppy mature code base. + 'minimum_severity' => Issue::SEVERITY_LOW, + + // Add any issue types (such as `'PhanUndeclaredMethod'`) + // to this deny-list to inhibit them from being reported. + 'suppress_issue_types' => [], + + // A regular expression to match files to be excluded + // from parsing and analysis and will not be read at all. + // + // This is useful for excluding groups of test or example + // directories/files, unanalyzable files, or files that + // can't be removed for whatever reason. + // (e.g. `'@Test\.php$@'`, or `'@vendor/.*/(tests|Tests)/@'`) + 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@', + + // A list of files that will be excluded from parsing and analysis + // and will not be read at all. + // + // This is useful for excluding hopelessly unanalyzable + // files that can't be removed for whatever reason. + 'exclude_file_list' => [ + 'vendor/composer/composer/src/Composer/InstalledVersions.php' + ], + + // A directory list that defines files that will be excluded + // from static analysis, but whose class and method + // information should be included. + // + // Generally, you'll want to include the directories for + // third-party code (such as "vendor/") in this list. + // + // n.b.: If you'd like to parse but not analyze 3rd + // party code, directories containing that code + // should be added to the `directory_list` as well as + // to `exclude_analysis_directory_list`. + 'exclude_analysis_directory_list' => [ + 'vendor/', + 'proto/', + 'thrift/' + ], + + // Enable this to enable checks of require/include statements referring to valid paths. + 'enable_include_path_checks' => true, + + // The number of processes to fork off during the analysis + // phase. + 'processes' => 1, + + // List of case-insensitive file extensions supported by Phan. + // (e.g. `['php', 'html', 'htm']`) + 'analyzed_file_extensions' => [ + 'php', + ], + + // You can put paths to stubs of internal extensions in this config option. + // If the corresponding extension is **not** loaded, then Phan will use the stubs instead. + // Phan will continue using its detailed type annotations, + // but load the constants, classes, functions, and classes (and their Reflection types) + // from these stub files (doubling as valid php files). + // Use a different extension from php to avoid accidentally loading these. + // The `tools/make_stubs` script can be used to generate your own stubs (compatible with php 7.0+ right now) + // + // (e.g. `['xdebug' => '.phan/internal_stubs/xdebug.phan_php']`) + 'autoload_internal_extension_signatures' => [], + + // A list of plugin files to execute. + // + // Plugins which are bundled with Phan can be added here by providing their name (e.g. `'AlwaysReturnPlugin'`) + // + // Documentation about available bundled plugins can be found [here](https://github.com/phan/phan/tree/master/.phan/plugins). + // + // Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. `'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php'`) + 'plugins' => [ + 'AlwaysReturnPlugin', + 'PregRegexCheckerPlugin', + 'UnreachableCodePlugin', + ], + + // A list of directories that should be parsed for class and + // method information. After excluding the directories + // defined in `exclude_analysis_directory_list`, the remaining + // files will be statically analyzed for errors. + // + // Thus, both first-party and third-party code being used by + // your application should be included in this list. + 'directory_list' => [ + 'src', + 'vendor' + ], + + // A list of individual files to include in analysis + // with a path relative to the root directory of the + // project. + 'file_list' => [], +]; diff --git a/src/Exporter/Instana/.php-cs-fixer.php b/src/Exporter/Instana/.php-cs-fixer.php new file mode 100644 index 00000000..e35fa078 --- /dev/null +++ b/src/Exporter/Instana/.php-cs-fixer.php @@ -0,0 +1,43 @@ +exclude('vendor') + ->exclude('var/cache') + ->in(__DIR__); + +$config = new PhpCsFixer\Config(); +return $config->setRules([ + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'is_null' => true, + 'modernize_types_casting' => true, + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'single_line_comment_style' => true, + 'yoda_style' => false, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => true, + 'cast_spaces' => true, + 'declare_strict_types' => true, + 'type_declaration_spaces' => true, + 'include' => true, + 'lowercase_cast' => true, + 'new_with_parentheses' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'echo_tag_syntax' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_types' => true, + 'short_scalar_cast' => true, + 'blank_lines_before_namespace' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); + diff --git a/src/Exporter/Instana/README.md b/src/Exporter/Instana/README.md new file mode 100644 index 00000000..764283e6 --- /dev/null +++ b/src/Exporter/Instana/README.md @@ -0,0 +1,76 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/opentelemetry-php/contrib-exporter-instana/releases) +[![Issues](https://img.shields.io/badge/issues-pink)](https://www.ibm.com/support/pages/instana-support) +[![Source](https://img.shields.io/badge/source-contrib-green)](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Exporter/Instana) +[![Mirror](https://img.shields.io/badge/mirror-opentelemetry--php--contrib-blue)](https://github.com/opentelemetry-php/contrib-exporter-instana) +[![Latest Version](http://poser.pugx.org/open-telemetry/opentelemetry-instana-exporter/v/unstable)](https://packagist.org/packages/open-telemetry/opentelemetry-exporter-instana/) +[![Stable](http://poser.pugx.org/open-telemetry/opentelemetry-instana-exporter/v/stable)](https://packagist.org/packages/open-telemetry/opentelemetry-exporter-instana/) + +This is a read-only subtree split of https://github.com/open-telemetry/opentelemetry-php-contrib. + +# Instana OpenTelemetry PHP Exporter + +Instana exporter for OpenTelemetry. + +## Documentation + +https://www.ibm.com/docs/en/instana-observability/current?topic=php-opentelemetry-exporter + +## Installing via Composer + +Install Composer in a common location or in your project + +```bash +curl -s https://getcomposer.org/installer | php +``` + +Install via Composer + +```bash +composer require open-telemetry/opentelemetry-exporter-instana +``` + +## Usage + + +Utilizing the OpenTelemetry PHP SDK, we can send spans natively to Instana, by providing an OpenTelemetry span processor our `SpanExporterInterface`. + +This can be manually constructed, or created from the `SpanExporterFactory`. See the factory implementation for how to manually construct the `SpanExporter`. The factory reads from two environment variables which can be set according, else will fallback onto the following defaults + +```bash +INSTANA_AGENT_HOST=127.0.0.1 +INSTANA_AGENT_PORT=42699 +``` + +The service name that is visible in the Instana UI can be configured with the following environment variables. OpenTelemetry provides `OTEL_SERVICE_NAME` (see documentation [here](https://opentelemetry.io/docs/languages/sdk-configuration/general/#otel_service_name)) as a way to customize this within the SDK. We also provide `INSTANA_SERVICE_NAME` which will be taken as the highest precedence. + +```bash +export INSTANA_SERVICE_NAME=custom-service-name +``` + +## Example + +```php +use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; +use OpenTelemetry\SDK\Trace\TracerProvider; + +$tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + Registry::spanExporterFactory("instana")->create() + ) +); +$tracer = $tracerProvider->getTracer('io.instana.opentelemetry.php'); + +$span = $tracer->spanBuilder('root')->startSpan(); +$span->setAttribute('remote_ip', '1.2.3.4') + ->setAttribute('country', 'CAN'); +$span->addEvent('generated_session', [ + 'id' => md5((string) microtime(true)), +]); +$span->end(); + +$tracerProvider->shutdown(); +``` + +## Issues + +This exporter is primarily maintained by contributors from IBM. Issues should be reported as part of standard [Instana product support](https://www.ibm.com/support/pages/instana-support). diff --git a/src/Exporter/Instana/_register.php b/src/Exporter/Instana/_register.php new file mode 100644 index 00000000..d17a0bc0 --- /dev/null +++ b/src/Exporter/Instana/_register.php @@ -0,0 +1,8 @@ + + + + + + + src + + + + + + + + + + + + + tests/Unit + + + + diff --git a/src/Exporter/Instana/psalm.xml.dist b/src/Exporter/Instana/psalm.xml.dist new file mode 100644 index 00000000..15571171 --- /dev/null +++ b/src/Exporter/Instana/psalm.xml.dist @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/Exporter/Instana/src/InstanaTransport.php b/src/Exporter/Instana/src/InstanaTransport.php new file mode 100644 index 00000000..dfab4bb9 --- /dev/null +++ b/src/Exporter/Instana/src/InstanaTransport.php @@ -0,0 +1,259 @@ +headers += ['Content-Type' => self::CONTENT_TYPE]; + if ($timeout > 0.0) { + $this->headers += ['timeout' => $timeout]; + } + + $this->client = new Client(['base_uri' => $endpoint]); + $this->announce(); + } + + /** + * @suppress PhanUndeclaredClassAttribute + */ + #[\Override] + public function contentType(): string + { + return self::CONTENT_TYPE; + } + + /** + * @suppress PhanUndeclaredClassAttribute + */ + #[\Override] + public function send(string $payload, ?CancellationInterface $cancellation = null): FutureInterface + { + if ($this->closed) { + return new ErrorFuture(new BadMethodCallException('Transport closed')); + } + + $response = $this->sendPayload($payload); + + $code = $response->getStatusCode(); + if ($code < 200 || $code >= 300) { + self::logDebug('Sending failed with code ' . (string) $code); + + try { + $this->announce(); + } catch (Exception $e) { + return new ErrorFuture($e); + } + } + + return new CompletedFuture('Payload successfully sent'); + } + + private function sendPayload(string $payload): ResponseInterface + { + return $this->client->sendRequest( + new Request( + method: 'POST', + uri: new Uri('/com.instana.plugin.php/traces.' . (string) ($this->pid)), + headers: $this->headers, + body: $payload + ) + ); + } + + /** + * @suppress PhanUndeclaredClassAttribute + */ + #[\Override] + public function shutdown(?CancellationInterface $cancellation = null): bool + { + if ($this->closed) { + return false; + } + + return $this->closed = true; + } + + /** + * @suppress PhanUndeclaredClassAttribute + */ + #[\Override] + public function forceFlush(?CancellationInterface $cancellation = null): bool + { + return !$this->closed; + } + + private function announce() + { + for ($attempt = 0; $attempt < $this->attempts && !$this->performAnnounce(); $attempt++) { + self::logDebug('Discovery request failed, attempt ' . (string) $attempt); + sleep(5); + } + + if (null === $this->agent_uuid || null === $this->pid) { + throw new Exception('Failed announcement in transport. Missing pid or uuid from agent'); + } + } + + private function performAnnounce(): bool + { + self::logDebug('Announcing to ' . $this->endpoint); + + // Phase 1) Host lookup. + $response = $this->client->sendRequest( + new Request(method: 'GET', uri: new Uri('/'), headers: $this->headers) + ); + + $code = $response->getStatusCode(); + $msg = $response->getBody()->getContents(); + + if ($code != 200 && !array_key_exists('version', json_decode($msg, true))) { + self::logError('Failed to lookup host. Received code ' . (string) $code . ' with message: ' . $msg); + $this->closed = true; + + return false; + } + + self::logDebug('Phase 1 announcement response code ' . (string) $code); + + // Phase 2) Announcement. + $response = $this->client->sendRequest( + new Request( + method: 'PUT', + uri: new Uri('/com.instana.plugin.php.discovery'), + headers: $this->headers, + body: $this->getAnnouncementPayload() + ) + ); + + $code = $response->getStatusCode(); + $msg = $response->getBody()->getContents(); + + self::logDebug('Phase 2 announcement response code ' . (string) $code); + + if ($code < 200 || $code >= 300) { + self::logError('Failed announcement. Received code ' . (string) $code . ' with message: ' . $msg); + $this->closed = true; + + return false; + } + + $content = json_decode($msg, true); + if (!array_key_exists('pid', $content)) { + self::logError('Failed to receive a pid from agent'); + $this->closed = true; + + return false; + } + + $this->pid = $content['pid']; + $this->agent_uuid = $content['agentUuid']; + + // Optional values that we may receive from the agent. + if (array_key_exists('secrets', $content)) { + $this->secrets = $content['secrets']; + } + if (array_key_exists('tracing', $content)) { + $this->tracing = $content['tracing']; + } + + // Phase 3) Wait for the agent ready signal. + for ($retry = 0; $retry < 2; $retry++) { + if ($retry) { + self::logDebug('Agent not yet ready, attempt ' . (string) $retry); + } + + $response = $this->client->sendRequest( + new Request( + method: 'HEAD', + uri: new Uri('/com.instana.plugin.php.' . $this->pid), + headers: $this->headers + ) + ); + + $code = $response->getStatusCode(); + self::logDebug('Phase 3 announcement endpoint status ' . (string) $code); + if ($code >= 200 && $code < 300) { + $this->closed = false; + + return true; + } + + sleep(1); + } + + $this->closed = true; + + return false; + } + + /** + * @psalm-suppress FalsableReturnStatement + */ + private function getAnnouncementPayload(): string + { + $cmdline_args = file_get_contents('/proc/self/cmdline'); + $cmdline_args = explode("\0", (string) $cmdline_args); + $cmdline_args = array_slice($cmdline_args, 1, count($cmdline_args) - 2); + + return json_encode([ + 'pid' => getmypid(), + 'pidFromParentNS' => false, + 'pidNamespace' => readlink('/proc/self/ns/pid'), + 'name' => readlink('/proc/self/exe'), + 'args' => $cmdline_args, + 'cpuSetFileContent' => '/', + 'fd' => null, + 'inode' => null, + ]); + } + + public function getPid(): ?string + { + return null === $this->pid ? null : (string) ($this->pid); + } + + public function getUuid(): ?string + { + return $this->agent_uuid; + } +} diff --git a/src/Exporter/Instana/src/SpanConverter.php b/src/Exporter/Instana/src/SpanConverter.php new file mode 100644 index 00000000..b7d3e827 --- /dev/null +++ b/src/Exporter/Instana/src/SpanConverter.php @@ -0,0 +1,231 @@ +defaultServiceName = ResourceInfoFactory::defaultResource()->getAttributes()->get(ResourceAttributes::SERVICE_NAME); + } + + /** + * @suppress PhanUndeclaredClassAttribute + */ + #[\Override] + public function convert(iterable $spans): array + { + $aggregate = []; + foreach ($spans as $span) { + $aggregate[] = $this->convertSpan($span); + } + + return $aggregate; + } + + private function convertSpan(SpanDataInterface $span): array + { + $startTimestamp = self::nanosToMillis($span->getStartEpochNanos()); + $endTimestamp = self::nanosToMillis($span->getEndEpochNanos()); + + if (null === $this->agentUuid || null === $this->agentPid) { + throw new Exception('Failed to get agentUuid or agentPid'); + } + + $instanaSpan = [ + 'n' => 'php', + 't' => $span->getTraceId(), + 's' => $span->getSpanId(), + 'ts' => $startTimestamp, + 'd' => max(0, $endTimestamp - $startTimestamp), + 'f' => ['e' => $this->agentPid, 'h' => $this->agentUuid], + 'data' => [], + ]; + + if ($span->getParentContext()->isValid()) { + $instanaSpan['p'] = $span->getParentSpanId(); + $instanaSpan['n'] = 'sdk'; + } + + $convertedKind = SpanConverter::toSpanKind($span); + if (null !== $convertedKind) { + $instanaSpan['k'] = $convertedKind; + } + + $serviceName = $span->getResource()->getAttributes()->get(ResourceAttributes::SERVICE_NAME) ?? $this->defaultServiceName; + if (Configuration::has('INSTANA_SERVICE_NAME')) { + $serviceName = Configuration::getString('INSTANA_SERVICE_NAME'); + } + $instanaSpan['data']['service'] = $serviceName; + + $instanaSpan['data']['sdk']['name'] = $span->getName() ?: 'sdk'; + $instanaSpan['data']['sdk']['custom']['tags'] = []; + foreach ($span->getResource()->getAttributes() as $key => $attrb) { + if (str_contains($key, 'service.')) { + continue; + } + $instanaSpan['data']['sdk']['custom']['tags'][$key] = $attrb; + } + + foreach ($span->getAttributes() as $key => $attrb) { + self::setOrAppend('attributes', $instanaSpan['data']['sdk']['custom']['tags'], [$key => $attrb]); + } + + foreach ($span->getEvents() as $event) { + self::setOrAppend('events', $instanaSpan['data']['sdk']['custom']['tags'], [$event->getName() => self::convertEvent($event)]); + } + + foreach ($span->getInstrumentationScope()->getAttributes() as $key => $value) { + self::setOrAppend('otel', $instanaSpan['data']['sdk']['custom']['tags'], [$key => $value]); + } + + if (!empty($span->getInstrumentationScope()->getName())) { + self::setOrAppend('otel', $instanaSpan['data']['sdk']['custom']['tags'], [self::OTEL_KEY_INSTRUMENTATION_SCOPE_NAME => $span->getInstrumentationScope()->getName()]); + } + + if (null !== $span->getInstrumentationScope()->getVersion()) { + self::setOrAppend('otel', $instanaSpan['data']['sdk']['custom']['tags'], [self::OTEL_KEY_INSTRUMENTATION_SCOPE_VERSION => $span->getInstrumentationScope()->getVersion()]); + } + + if ($span->getStatus()->getCode() !== StatusCode::STATUS_UNSET) { + self::setOrAppend('otel', $instanaSpan['data']['sdk']['custom']['tags'], [self::OTEL_KEY_STATUS_CODE => $span->getStatus()->getCode()]); + } + + if ($span->getStatus()->getCode() === StatusCode::STATUS_ERROR) { + self::setOrAppend('otel', $instanaSpan['data']['sdk']['custom']['tags'], [self::OTEL_KEY_STATUS_DESCRIPTION => $span->getStatus()->getDescription()]); + } + + $droppedAttributes = $span->getAttributes()->getDroppedAttributesCount() + + $span->getInstrumentationScope()->getAttributes()->getDroppedAttributesCount() + + $span->getResource()->getAttributes()->getDroppedAttributesCount(); + + if ($droppedAttributes > 0) { + self::setOrAppend('otel', $instanaSpan['data']['sdk']['custom']['tags'], [self::OTEL_KEY_DROPPED_ATTRIBUTES_COUNT => $droppedAttributes]); + } + + if ($span->getTotalDroppedEvents() > 0) { + self::setOrAppend('otel', $instanaSpan['data']['sdk']['custom']['tags'], [self::OTEL_KEY_DROPPED_EVENTS_COUNT => $span->getTotalDroppedEvents()]); + } + + if ($span->getTotalDroppedLinks() > 0) { + self::setOrAppend('otel', $instanaSpan['data']['sdk']['custom']['tags'], [self::OTEL_KEY_DROPPED_LINKS_COUNT => $span->getTotalDroppedLinks()]); + } + $extraRequestHeaders = []; + $extraResponseHeaders = []; + $extraResponseHeaders = array_merge($extraResponseHeaders, Configuration::getList('OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS', [])); + + $extraRequestHeaders = array_merge( + $extraRequestHeaders, + Configuration::getList('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS', []) + ); + + if (array_key_exists('attributes', $instanaSpan['data']['sdk']['custom']['tags'])) { + $keys = array_filter($instanaSpan['data']['sdk']['custom']['tags']['attributes'], function ($k) use ($extraRequestHeaders) { + $matches = []; + + return preg_match('/http\.(request)\.header\.(.+)/', $k, $matches) && + !in_array($matches[2], $extraRequestHeaders); + }, ARRAY_FILTER_USE_KEY); + + $keys += array_filter($instanaSpan['data']['sdk']['custom']['tags']['attributes'], function ($k) use ($extraResponseHeaders) { + $matches = []; + + return preg_match('/http\.(response)\.header\.(.+)/', $k, $matches) && + !in_array($matches[2], $extraResponseHeaders); + }, ARRAY_FILTER_USE_KEY); + + // @phpstan-ignore-next-line + foreach ($keys as $k => $_v) { + unset($instanaSpan['data']['sdk']['custom']['tags']['attributes'][$k]); + } + } + + self::unsetEmpty($instanaSpan['data']); + + return $instanaSpan; + } + + private static function toSpanKind(SpanDataInterface $span): ?int + { + return match ($span->getKind()) { + SpanKind::KIND_SERVER => InstanaSpanKind::ENTRY, + SpanKind::KIND_CLIENT => InstanaSpanKind::EXIT, + SpanKind::KIND_PRODUCER => InstanaSpanKind::EXIT, + SpanKind::KIND_CONSUMER => InstanaSpanKind::ENTRY, + SpanKind::KIND_INTERNAL => InstanaSpanKind::INTERMEDIATE, + default => null, + }; + } + + private static function nanosToMillis(int $nanoseconds): int + { + return intdiv($nanoseconds, ClockInterface::NANOS_PER_MILLISECOND); + } + + private static function setOrAppend(string $key, array &$arr, mixed $value): void + { + if (array_key_exists($key, $arr)) { + if (!is_array($arr[$key])) { + $arr[$key] = [$arr[$key]]; + } + $arr[$key] += is_array($value) ? $value : [$value]; + } else { + $arr[$key] = $value; + } + } + + private static function unsetEmpty(array &$arr): void + { + foreach ($arr as $key => $value) { + if (is_array($value)) { + self::unsetEmpty($arr[$key]); + if (empty($arr[$key])) { + unset($arr[$key]); + } + } elseif (null === $value) { + unset($arr[$key]); + } + } + } + + private static function convertEvent(EventInterface $event): string + { + if (count($event->getAttributes()) === 0) { + return '{}'; + } + + $res = json_encode([ + 'value' => $event->getAttributes()->toArray(), + 'timestamp' => self::nanosToMillis($event->getEpochNanos()), + ]); + + return ($res === false) ? '{}' : $res; + } +} diff --git a/src/Exporter/Instana/src/SpanExporter.php b/src/Exporter/Instana/src/SpanExporter.php new file mode 100644 index 00000000..54c31338 --- /dev/null +++ b/src/Exporter/Instana/src/SpanExporter.php @@ -0,0 +1,77 @@ +setSpanConverter($spanConverter); + } + + /** + * @throws JsonException + */ + protected function serializeTrace(iterable $spans): string + { + return json_encode( + $this->getSpanConverter()->convert($spans), + JSON_THROW_ON_ERROR + ); + } + + /** + * @suppress PhanUndeclaredClassAttribute + */ + #[\Override] + public function export(iterable $batch, ?CancellationInterface $cancellation = null): FutureInterface + { + return $this->transport + ->send($this->serializeTrace($batch), $cancellation) + ->map(static fn (): bool => true) + ->catch(static function (Throwable $throwable): bool { + self::logError('Export failure', ['exception' => $throwable]); + + return false; + }); + } + + /** + * @suppress PhanUndeclaredClassAttribute + */ + #[\Override] + public function shutdown(?CancellationInterface $cancellation = null): bool + { + return $this->transport->shutdown($cancellation); + } + + /** + * @suppress PhanUndeclaredClassAttribute + */ + #[\Override] + public function forceFlush(?CancellationInterface $cancellation = null): bool + { + return $this->transport->forceFlush($cancellation); + } +} diff --git a/src/Exporter/Instana/src/SpanExporterFactory.php b/src/Exporter/Instana/src/SpanExporterFactory.php new file mode 100644 index 00000000..c3e03690 --- /dev/null +++ b/src/Exporter/Instana/src/SpanExporterFactory.php @@ -0,0 +1,40 @@ +getUuid(); + $pid = $transport->getPid(); + $converter = new SpanConverter($uuid, $pid); + + return new SpanExporter($transport, $converter); + } +} diff --git a/src/Exporter/Instana/src/SpanKind.php b/src/Exporter/Instana/src/SpanKind.php new file mode 100644 index 00000000..f02d3add --- /dev/null +++ b/src/Exporter/Instana/src/SpanKind.php @@ -0,0 +1,12 @@ +converter = new SpanConverter('0123456abcdef', '12345'); + } + + public function test_should_convert_a_span_to_a_payload_for_instana(): void + { + putenv('INSTANA_SERVICE_NAME=instana/opentelemetry-exporter-instana'); + $span = (new SpanDataUtil()) + ->setName('converter.test') + ->setKind(OtelSpanKind::KIND_CLIENT) + ->setContext( + SpanContext::create( + 'abcdef0123456789abcdef0123456789', + 'aabbccddeeff0123' + ) + ) + ->setParentContext( + SpanContext::create( + '10000000000000000000000000000000', + '1000000000000000' + ) + ) + ->setStatus( + new StatusData( + StatusCode::STATUS_ERROR, + 'status_description' + ) + ) + ->setInstrumentationScope(new InstrumentationScope( + 'instrumentation_scope_name', + 'instrumentation_scope_version', + null, + Attributes::create([]), + )) + ->addAttribute('service', ['name' => 'instana/opentelemetry-exporter-instana', 'version' => 'dev-main']) + ->addAttribute('net.peer.name', 'authorizationservice.com') + ->addAttribute('peer.service', 'AuthService') + ->setResource( + ResourceInfo::create( + Attributes::create([ + 'telemetry.sdk.name' => 'opentelemetry', + 'telemetry.sdk.language' => 'php', + 'telemetry.sdk.version' => 'dev', + 'instance' => 'test-a', + ]) + ) + ) + ->addEvent('validators.list', Attributes::create(['job' => 'stage.updateTime']), 1505855799433901068) + ->setHasEnded(true); + + $instanaSpan = $this->converter->convert([$span])[0]; + $this->assertSame('sdk', $instanaSpan['n']); + $this->assertSame($span->getContext()->getTraceId(), $instanaSpan['t']); + $this->assertSame($span->getContext()->getSpanId(), $instanaSpan['s']); + $this->assertSame(1505855794194, $instanaSpan['ts']); + $this->assertSame(5271, $instanaSpan['d']); + $this->assertSame('12345', $instanaSpan['f']['e']); + $this->assertSame('0123456abcdef', $instanaSpan['f']['h']); + $this->assertSame('1000000000000000', $instanaSpan['p']); + $this->assertSame(2, $instanaSpan['k']); + + $this->assertCount(2, $instanaSpan['data']); + $this->assertSame('instana/opentelemetry-exporter-instana', $instanaSpan['data']['service']); + $this->assertSame($span->getName(), $instanaSpan['data']['sdk']['name']); + + $tags = $instanaSpan['data']['sdk']['custom']['tags']; + $this->assertCount(7, $tags); + $this->assertSame('opentelemetry', $tags['telemetry.sdk.name']); + $this->assertSame('php', $tags['telemetry.sdk.language']); + $this->assertSame('dev', $tags['telemetry.sdk.version']); + $this->assertSame('test-a', $tags['instance']); + + $this->assertCount(3, $tags['attributes']); + $this->assertSame('instana/opentelemetry-exporter-instana', $tags['attributes']['service']['name']); + $this->assertSame('dev-main', $tags['attributes']['service']['version']); + $this->assertSame('authorizationservice.com', $tags['attributes']['net.peer.name']); + $this->assertSame('AuthService', $tags['attributes']['peer.service']); + + $this->assertSame('{"value":{"job":"stage.updateTime"},"timestamp":1505855799433}', $tags['events']['validators.list']); + + $this->assertCount(4, $tags['otel']); + $this->assertSame('instrumentation_scope_name', $tags['otel']['scope.name']); + $this->assertSame('instrumentation_scope_version', $tags['otel']['scope.version']); + $this->assertSame('Error', $tags['otel']['status_code']); + $this->assertSame('status_description', $tags['otel']['error']); + } + + /** + * @dataProvider spanConverterProvider + */ + public function test_should_throw_on_missing_construction(SpanConverter $converter): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Failed to get agentUuid or agentPid'); + $span = (new SpanDataUtil()); + $converter->convert([$span]); + } + + public static function spanConverterProvider(): array + { + return [ + 'default' => [new SpanConverter()], + 'wo_uuid' => [new SpanConverter(agentPid: '12345')], + 'wo_pid' => [new SpanConverter(agentUuid: '0123456abcdef')], + ]; + } + + public function test_should_omit_empty_keys_from_instana_span(): void + { + $span = (new SpanDataUtil()); + $instanaSpan = $this->converter->convert([$span])[0]; + + $this->assertArrayNotHasKey('p', $instanaSpan); + $this->assertSame('php', $instanaSpan['n']); + $this->assertSame('instana/opentelemetry-exporter-instana', $instanaSpan['data']['service']); + $this->assertSame('test-span-data', $instanaSpan['data']['sdk']['name']); + $this->assertCount(2, $instanaSpan['data']); + } + + /** + * @dataProvider spanKindProvider + */ + public function test_should_convert_otel_span_to_an_instana_span(int $internalSpanKind, ?int $expectedSpanKind): void + { + $span = (new SpanDataUtil()) + ->setKind($internalSpanKind); + + $instanaSpan = $this->converter->convert([$span])[0]; + + if ($internalSpanKind < 5) { + $this->assertSame($expectedSpanKind, $instanaSpan['k']); + } else { + $this->assertArrayNotHasKey('k', $instanaSpan); + } + } + + public static function spanKindProvider(): array + { + return [ + 'server' => [OtelSpanKind::KIND_SERVER, SpanKind::ENTRY], + 'client' => [OtelSpanKind::KIND_CLIENT, SpanKind::EXIT], + 'producer' => [OtelSpanKind::KIND_PRODUCER, SpanKind::EXIT], + 'consumer' => [OtelSpanKind::KIND_CONSUMER, SpanKind::ENTRY], + 'consumer_internal' => [OtelSpanKind::KIND_INTERNAL, SpanKind::INTERMEDIATE], + 'default' => [12345, null], // Some unsupported "enum" + ]; + } + + public function test_should_convert_an_event_without_attributes_to_an_empty_event(): void + { + $span = (new SpanDataUtil()) + ->addEvent('event.name', Attributes::create([])); + + $instanaSpan = $this->converter->convert([$span])[0]; + + $this->assertSame('{}', $instanaSpan['data']['sdk']['custom']['tags']['events']['event.name']); + } + + /** + * @psalm-suppress UndefinedInterfaceMethod,PossiblyInvalidArrayAccess + */ + public function test_data_are_coerced_correctly_to_strings(): void + { + $listOfStrings = ['string-1', 'string-2']; + $listOfNumbers = [1, 2, 3, 3.1415, 42]; + $listOfBooleans = [true, true, false, true]; + + $span = (new SpanDataUtil()) + ->addAttribute('string', 'string') + ->addAttribute('integer-1', 1024) + ->addAttribute('integer-2', 0) + ->addAttribute('float', 1.2345) + ->addAttribute('boolean-1', true) + ->addAttribute('boolean-2', false) + ->addAttribute('list-of-strings', $listOfStrings) + ->addAttribute('list-of-numbers', $listOfNumbers) + ->addAttribute('list-of-booleans', $listOfBooleans); + + $data = $this->converter->convert([$span])[0]['data']['sdk']['custom']['tags']['attributes']; + + // Check that we captured all attributes in data. + $this->assertCount(9, $data); + + $this->assertSame('string', $data['string']); + $this->assertSame(1024, $data['integer-1']); + $this->assertSame(0, $data['integer-2']); + $this->assertSame(1.2345, $data['float']); + $this->assertTrue($data['boolean-1']); + $this->assertFalse($data['boolean-2']); + + // Lists are recovered and are the same. + $this->assertSame($listOfStrings, $data['list-of-strings']); + $this->assertSame($listOfNumbers, $data['list-of-numbers']); + $this->assertSame($listOfBooleans, $data['list-of-booleans']); + } + + /** + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/common/mapping-to-non-otlp.md#dropped-attributes-count + */ + /** + * @dataProvider droppedProvider + */ + public function test_displays_non_zero_dropped_counts(int $dropped, bool $expected): void + { + $attributes = $this->createMock(AttributesInterface::class); + $attributes->method('getDroppedAttributesCount')->willReturn($dropped); + $spanData = $this->createMock(SpanDataInterface::class); + $spanData->method('getAttributes')->willReturn($attributes); + $spanData->method('getLinks')->willReturn([]); + $spanData->method('getEvents')->willReturn([]); + $spanData->method('getTotalDroppedEvents')->willReturn($dropped); + $spanData->method('getTotalDroppedLinks')->willReturn($dropped); + + $converted = $this->converter->convert([$spanData])[0]; + $data = $converted['data']['sdk']['custom']['tags']['otel']; + + if ($expected) { + $this->assertArrayHasKey(SpanConverter::OTEL_KEY_DROPPED_EVENTS_COUNT, $data); + $this->assertSame($dropped, $data[SpanConverter::OTEL_KEY_DROPPED_EVENTS_COUNT]); + $this->assertArrayHasKey(SpanConverter::OTEL_KEY_DROPPED_LINKS_COUNT, $data); + $this->assertSame($dropped, $data[SpanConverter::OTEL_KEY_DROPPED_LINKS_COUNT]); + $this->assertArrayHasKey(SpanConverter::OTEL_KEY_DROPPED_ATTRIBUTES_COUNT, $data); + $this->assertSame($dropped, $data[SpanConverter::OTEL_KEY_DROPPED_ATTRIBUTES_COUNT]); + } else { + $this->assertArrayNotHasKey(SpanConverter::OTEL_KEY_DROPPED_EVENTS_COUNT, $data); + $this->assertArrayNotHasKey(SpanConverter::OTEL_KEY_DROPPED_LINKS_COUNT, $data); + $this->assertArrayNotHasKey(SpanConverter::OTEL_KEY_DROPPED_ATTRIBUTES_COUNT, $data); + } + } + + public static function droppedProvider(): array + { + return [ + 'no dropped' => [0, false], + 'some dropped' => [1, true], + ]; + } + + public function test_events(): void + { + $eventAttributes = $this->createMock(AttributesInterface::class); + $eventAttributes->method('getDroppedAttributesCount')->willReturn(99); + $attributes = [ + 'a_one' => 123, + 'a_two' => 3.14159, + 'a_three' => true, + 'a_four' => false, + ]; + $eventAttributes->method('count')->willReturn(count($attributes)); + $eventAttributes->method('toArray')->willReturn($attributes); + $span = (new SpanDataUtil()) + ->setName('events.test') + ->addEvent('event.one', $eventAttributes); + $instanaSpan = $this->converter->convert([$span])[0]; + + $events = $instanaSpan['data']['sdk']['custom']['tags']['events']; + + $this->assertTrue(array_key_exists('event.one', $events)); + $this->assertIsString($events['event.one']); + } + + public function test_http_header_attributes(): void + { + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS=secrets'); + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=agent'); + + $span = (new SpanDataUtil()) + ->setName('converter.http') + ->setKind(OtelSpanKind::KIND_CLIENT) + ->addAttribute('http.request.method', 'GET') + ->addAttribute('http.request.header', ['secret' => 'foo']) + ->addAttribute('http.request.header.secrets', ['foo', 'bar']) + ->addAttribute('http.response.status_code', 200) + ->addAttribute('http.response.header.secrets', ['fizz', 'buzz']) + ->addAttribute('http.response.header.agent', 'instana') + ->addAttribute('http.request.header.agent', 'instana'); + + $data = $this->converter->convert([$span])[0]['data']['sdk']['custom']['tags']; + + $this->assertArrayHasKey('http.request.method', $data['attributes']); + $this->assertArrayHasKey('http.response.status_code', $data['attributes']); + + $this->assertArrayHasKey('http.request.header.agent', $data['attributes']); + $this->assertArrayHasKey('http.response.header.secrets', $data['attributes']); + $this->assertArrayNotHasKey('http.request.header.secrets', $data['attributes']); + $this->assertArrayNotHasKey('http.response.header.agent', $data['attributes']); + + } +} diff --git a/src/Exporter/Instana/tests/Unit/SpanDataUtil.php b/src/Exporter/Instana/tests/Unit/SpanDataUtil.php new file mode 100644 index 00000000..6f2bee89 --- /dev/null +++ b/src/Exporter/Instana/tests/Unit/SpanDataUtil.php @@ -0,0 +1,244 @@ + */ + private array $events = []; + + /** @var list + */ + private array $links = []; + + private AttributesBuilderInterface $attributesBuilder; + private int $kind = API\SpanKind::KIND_INTERNAL; + private StatusData $status; + private ResourceInfo $resource; + private InstrumentationScope $instrumentationScope; + private API\SpanContextInterface $context; + private API\SpanContextInterface $parentContext; + private int $totalRecordedEvents = 0; + private int $totalRecordedLinks = 0; + private int $startEpochNanos = 1505855794194009601; + private int $endEpochNanos = 1505855799465726528; + private bool $hasEnded = false; + + public function __construct() + { + $this->attributesBuilder = Attributes::factory()->builder(); + $this->status = StatusData::unset(); + $this->resource = ResourceInfoFactory::emptyResource(); + $this->instrumentationScope = new InstrumentationScope('', null, null, Attributes::create([])); + $this->context = API\SpanContext::getInvalid(); + $this->parentContext = API\SpanContext::getInvalid(); + } + + #[\Override] + public function getName(): string + { + return $this->name; + } + + /** @param non-empty-string $name */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** @inheritDoc */ + #[\Override] + public function getLinks(): array + { + return $this->links; + } + + /** @inheritDoc */ + #[\Override] + public function getEvents(): array + { + return $this->events; + } + + public function addEvent(string $name, AttributesInterface $attributes, ?int $timestamp = null): self + { + $this->events[] = new SDK\Event($name, $timestamp ?? Clock::getDefault()->now(), $attributes); + + return $this; + } + + #[\Override] + public function getAttributes(): AttributesInterface + { + return $this->attributesBuilder->build(); + } + + public function addAttribute(string $key, $value): self + { + $this->attributesBuilder[$key] = $value; + + return $this; + } + + #[\Override] + public function getTotalDroppedEvents(): int + { + return max(0, $this->totalRecordedEvents - count($this->events)); + } + + #[\Override] + public function getTotalDroppedLinks(): int + { + return max(0, $this->totalRecordedLinks - count($this->links)); + } + + #[\Override] + public function getKind(): int + { + return $this->kind; + } + + public function setKind(int $kind): self + { + $this->kind = $kind; + + return $this; + } + + #[\Override] + public function getStatus(): StatusData + { + return $this->status; + } + + public function setStatus(StatusData $status): self + { + $this->status = $status; + + return $this; + } + + #[\Override] + public function getEndEpochNanos(): int + { + return $this->endEpochNanos; + } + + #[\Override] + public function getStartEpochNanos(): int + { + return $this->startEpochNanos; + } + + #[\Override] + public function hasEnded(): bool + { + return $this->hasEnded; + } + + public function setHasEnded(bool $hasEnded): self + { + $this->hasEnded = $hasEnded; + + return $this; + } + + #[\Override] + public function getResource(): ResourceInfo + { + return $this->resource; + } + + public function setResource(ResourceInfo $resource): self + { + $this->resource = $resource; + + return $this; + } + + #[\Override] + public function getInstrumentationScope(): InstrumentationScope + { + return $this->instrumentationScope; + } + + public function setInstrumentationScope(InstrumentationScope $instrumentationScope): self + { + $this->instrumentationScope = $instrumentationScope; + + return $this; + } + + #[\Override] + public function getContext(): API\SpanContextInterface + { + return $this->context; + } + + public function setContext(API\SpanContextInterface $context): self + { + $this->context = $context; + + return $this; + } + + #[\Override] + public function getParentContext(): API\SpanContextInterface + { + return $this->parentContext; + } + + public function setParentContext(API\SpanContextInterface $parentContext): self + { + $this->parentContext = $parentContext; + + return $this; + } + + #[\Override] + public function getTraceId(): string + { + return $this->getContext()->getTraceId(); + } + + #[\Override] + public function getSpanId(): string + { + return $this->getContext()->getSpanId(); + } + + #[\Override] + public function getParentSpanId(): string + { + return $this->getParentContext()->getSpanId(); + } +} diff --git a/src/Exporter/Instana/tests/Unit/SpanExporterTest.php b/src/Exporter/Instana/tests/Unit/SpanExporterTest.php new file mode 100644 index 00000000..ad98e78d --- /dev/null +++ b/src/Exporter/Instana/tests/Unit/SpanExporterTest.php @@ -0,0 +1,53 @@ +createMock(TransportInterface::class); + $transportMock + ->expects($this->any()) + ->method('send') + ->willReturn(new CompletedFuture('Payload successfully sent')); + $transportMock + ->expects($this->any()) + ->method('shutdown') + ->willReturn(true); + $transportMock + ->expects($this->any()) + ->method('forceFlush') + ->willReturn(true); + + $this->exporter = new SpanExporter( + $transportMock, + new SpanConverter('0123456abcdef', '12345') + ); + } + + public function test_calls_to_transportinterface(): void + { + $this->assertTrue($this->exporter->shutdown()); + $this->assertTrue($this->exporter->forceFlush()); + } + + public function test_successful_export(): void + { + $batch = [new SpanDataUtil(), new SpanDataUtil()]; + $ret = $this->exporter->export($batch); + + $this->assertSame($ret::class, CompletedFuture::class); + } +}