diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 28025abbf..022d20add 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -41,6 +41,7 @@ jobs: 'Instrumentation/MySqli', 'Instrumentation/OpenAIPHP', 'Instrumentation/PDO', + 'Instrumentation/PostgreSql', # Sort PSRs numerically. 'Instrumentation/Psr3', 'Instrumentation/Psr6', @@ -78,6 +79,8 @@ jobs: php-version: 8.1 - project: 'Instrumentation/PDO' php-version: 8.1 + - project: 'Instrumentation/PostgreSql' + php-version: 8.1 steps: - uses: actions/checkout@v4 @@ -86,7 +89,7 @@ jobs: with: php-version: ${{ matrix.php-version }} coverage: xdebug - extensions: ast, amqp, grpc, opentelemetry, rdkafka, mysqli + extensions: ast, amqp, grpc, opentelemetry, rdkafka, mysqli, pgsql - name: Validate composer.json and composer.lock run: composer validate @@ -151,6 +154,11 @@ jobs: run: | docker compose up mysql -d --wait + - name: Start PostgreSql + if: ${{ matrix.project == 'Instrumentation/PostgreSql' }} + run: | + docker compose up postgresql -d --wait + - name: Run PHPUnit working-directory: src/${{ matrix.project }} run: vendor/bin/phpunit diff --git a/.gitsplit.yml b/.gitsplit.yml index 2d1f9e186..66cac9025 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -44,6 +44,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-openai.git" - prefix: "src/Instrumentation/PDO" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-pdo.git" + - prefix: "src/Instrumentation/PostgreSql" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-postgresql.git" - prefix: "src/Instrumentation/Psr3" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-psr3.git" - prefix: "src/Instrumentation/Psr6" diff --git a/docker-compose.yaml b/docker-compose.yaml index 263e0d1bd..52746f6dd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,6 +18,7 @@ services: MONGODB_HOST: ${MONGODB_HOST:-mongodb} MONGODB_PORT: ${MONGODB_PORT:-27017} MYSQL_HOST: ${MYSQL_HOST:-mysql} + POSTGRESQL_HOST: ${POSTGRESQL_HOST:-postgresql} zipkin: image: openzipkin/zipkin-slim @@ -88,3 +89,20 @@ services: retries: 3 volumes: - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql + + postgresql: + image: postgres:17.5 + hostname: postgresql + ports: + - "5432:5432/tcp" + environment: + POSTGRES_DB: otel_db + POSTGRES_USER: otel_user + POSTGRES_PASSWORD: otel_passwd + healthcheck: + test: ["CMD-SHELL", "PGPASSWORD=otel_passwd psql -U otel_user -d otel_db -h 127.0.0.1 -c 'SELECT 1'"] + interval: 30s + timeout: 90s + retries: 3 + volumes: + - ./docker/postgresql/init.sql:/docker-entrypoint-initdb.d/init.sql diff --git a/docker/Dockerfile b/docker/Dockerfile index ccfe5b5c7..2e30b0bdd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,6 +8,7 @@ RUN install-php-extensions \ mongodb \ amqp \ rdkafka \ - mysqli + mysqli \ + pgsql USER php diff --git a/docker/postgresql/init.sql b/docker/postgresql/init.sql new file mode 100644 index 000000000..e698e9913 --- /dev/null +++ b/docker/postgresql/init.sql @@ -0,0 +1,34 @@ + +CREATE DATABASE otel_db2; + +\connect otel_db2; + +CREATE USER otel_user2 WITH PASSWORD 'otel_passwd'; + +GRANT ALL PRIVILEGES ON DATABASE otel_db2 TO otel_user2; + +\connect otel_db; + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO users (name, email) VALUES +('John Doe', 'john.doe@example.com'), +('Jane Smith', 'jane.smith@example.com'), +('Bob Johnson', 'bob.johnson@example.com'); + +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price NUMERIC(10, 2) NOT NULL, + stock INT NOT NULL DEFAULT 0 +); + +INSERT INTO products (name, price, stock) VALUES +('Laptop', 999.99, 10), +('Smartphone', 499.99, 25), +('Headphones', 49.99, 50); \ No newline at end of file diff --git a/src/Instrumentation/PostgreSql/.gitattributes b/src/Instrumentation/PostgreSql/.gitattributes new file mode 100644 index 000000000..1676cf825 --- /dev/null +++ b/src/Instrumentation/PostgreSql/.gitattributes @@ -0,0 +1,12 @@ +* 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 diff --git a/src/Instrumentation/PostgreSql/.gitignore b/src/Instrumentation/PostgreSql/.gitignore new file mode 100644 index 000000000..57872d0f1 --- /dev/null +++ b/src/Instrumentation/PostgreSql/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/src/Instrumentation/PostgreSql/.phan/config.php b/src/Instrumentation/PostgreSql/.phan/config.php new file mode 100644 index 000000000..6bf6f35c4 --- /dev/null +++ b/src/Instrumentation/PostgreSql/.phan/config.php @@ -0,0 +1,371 @@ + '8.2', + + // 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/Instrumentation/PostgreSql/.php-cs-fixer.php b/src/Instrumentation/PostgreSql/.php-cs-fixer.php new file mode 100644 index 000000000..e35fa078c --- /dev/null +++ b/src/Instrumentation/PostgreSql/.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/Instrumentation/PostgreSql/README.md b/src/Instrumentation/PostgreSql/README.md new file mode 100644 index 000000000..1bbdd22af --- /dev/null +++ b/src/Instrumentation/PostgreSql/README.md @@ -0,0 +1,72 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/open-telemetry/opentelemetry-php-contrib/releases) +[![Issues](https://img.shields.io/badge/issues-pink)](https://github.com/open-telemetry/opentelemetry-php/issues) +[![Source](https://img.shields.io/badge/source-contrib-green)](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Instrumentation/PostgreSql) +[![Mirror](https://img.shields.io/badge/mirror-opentelemetry--php--contrib-blue)](https://github.com/open-telemetry/opentelemetry-php-contrib) +[![Latest Version](http://poser.pugx.org/open-telemetry/opentelemetry-auto-postgresql/v/unstable)](https://packagist.org/packages/open-telemetry/opentelemetry-auto-postgresql/) +[![Stable](http://poser.pugx.org/open-telemetry/opentelemetry-auto-postgresql/v/stable)](https://packagist.org/packages/open-telemetry/opentelemetry-auto-postgresql/) + +> This is a read-only subtree split of https://github.com/open-telemetry/opentelemetry-php-contrib. + +# OpenTelemetry PostgreSQL auto-instrumentation + +Please read https://opentelemetry.io/docs/instrumentation/php/automatic/ for instructions on how to +install and configure the extension and SDK. + +## Overview + +This package provides auto-instrumentation for the PostgreSQL native PHP extension (`ext-pgsql`). +Hooks are registered via Composer, and client spans are automatically created for key database operations. + +Supported functions include: + +### Connection +- `pg_connect` +- `pg_pconnect` + +### Queries +- `pg_query` +- `pg_query_params` +- `pg_send_query` +- `pg_send_query_params` +- `pg_get_result` + +### Prepared Statements +- `pg_prepare` +- `pg_send_prepare` +- `pg_execute` +- `pg_send_execute` + +### Table/Row Operations +- `pg_insert` +- `pg_select` +- `pg_update` +- `pg_delete` + +### COPY +- `pg_copy_from` +- `pg_copy_to` + +### Large Objects (LOB) +- `pg_lo_create` +- `pg_lo_open` +- `pg_lo_write` +- `pg_lo_read` +- `pg_lo_read_all` +- `pg_lo_unlink` +- `pg_lo_import` +- `pg_lo_export` + + +## Configuration + +### Disabling PostgreSQL instrumentation + +The extension can be disabled via [runtime configuration](https://opentelemetry.io/docs/instrumentation/php/sdk/#configuration): + +```shell +OTEL_PHP_DISABLED_INSTRUMENTATIONS=postgresql +``` + +## Compatibility + +PHP 8.2 or newer is required diff --git a/src/Instrumentation/PostgreSql/_register.php b/src/Instrumentation/PostgreSql/_register.php new file mode 100644 index 000000000..6b6cd15ad --- /dev/null +++ b/src/Instrumentation/PostgreSql/_register.php @@ -0,0 +1,18 @@ + + + + + + + src + + + + + + + + + + + + + tests/Unit + + + tests/Integration + + + + diff --git a/src/Instrumentation/PostgreSql/psalm.xml.dist b/src/Instrumentation/PostgreSql/psalm.xml.dist new file mode 100644 index 000000000..5a04b34d7 --- /dev/null +++ b/src/Instrumentation/PostgreSql/psalm.xml.dist @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/src/Instrumentation/PostgreSql/src/PgSqlTracker.php b/src/Instrumentation/PostgreSql/src/PgSqlTracker.php new file mode 100644 index 000000000..63c160116 --- /dev/null +++ b/src/Instrumentation/PostgreSql/src/PgSqlTracker.php @@ -0,0 +1,269 @@ + + */ + private WeakMap $connectionStatements; + + /** + * WeakMap>> + */ + private WeakMap $connectionAsyncLink; + + /** + * WeakMap> + */ + private WeakMap $connectionLargeObjects; + + public function __construct() + { + // /** @psalm-suppress PropertyTypeCoercion */ + $this->connectionAttributes = new WeakMap(); + $this->connectionStatements = new WeakMap(); + $this->connectionAsyncLink = new WeakMap(); // maps connection to SplQueue with links + $this->connectionLargeObjects = new WeakMap(); // maps Lob to Connection + } + + public function addAsyncLinkForConnection(Connection $connection, SpanContextInterface $spanContext) + { + + if (!$this->connectionAsyncLink->offsetExists($connection)) { + $this->connectionAsyncLink[$connection] = new SplQueue(); + } + $this->connectionAsyncLink[$connection]->push(WeakReference::create($spanContext)); + } + + public function getAsyncLinkForConnection(Connection $connection) : ?SpanContextInterface + { + if (!$this->connectionAsyncLink->offsetExists($connection)) { + return null; + } + + if ($this->connectionAsyncLink[$connection]->isEmpty()) { + return null; + } + + return $this->connectionAsyncLink[$connection]->pop()->get(); + } + + public function addConnectionStatement(Connection $connection, string $statementName, string $query) + { + if (!$this->connectionStatements->offsetExists($connection)) { + $this->connectionStatements[$connection] = []; + } + $this->connectionStatements[$connection][$statementName] = $query; + } + + public function getStatementQuery(Connection $connection, string $statementName) : ?string + { + if ($this->connectionStatements->offsetExists($connection)) { + return $this->connectionStatements[$connection][$statementName] ?? null; + } + + return null; + } + + public function storeConnectionAttributes(Connection $connection, string $connectionString) + { + $this->connectionAttributes[$connection] = self::parseAttributesFromConnectionString($connectionString); + } + public function getConnectionAttributes(Connection $connection) : array + { + return $this->connectionAttributes[$connection] ?? []; + } + + public function trackConnectionFromLob(Connection $connection, Lob $lob) + { + $this->connectionLargeObjects[$lob] = WeakReference::create($connection); + } + + public function getConnectionFromLob(Lob $lob) : ?Connection + { + if ($this->connectionLargeObjects->offsetExists($lob)) { + return $this->connectionLargeObjects[$lob]->get(); + } + + return null; + } + + public static function splitQueries(string $sql) + { + // Normalize line endings to \n + $sql = preg_replace("/\r\n|\n\r|\r/", "\n", $sql); + if ($sql === null) { + return []; + } + + $queries = []; + $buffer = ''; + $blockDepth = 0; + $tokens = preg_split('/(;)/', $sql, -1, PREG_SPLIT_DELIM_CAPTURE); // Keep semicolons as separate tokens + + $singleQuotes = 0; + $doubleQuotes = 0; + + if (empty($tokens)) { + return []; + } + + foreach ($tokens as $token) { + if ($token === '') { + continue; + } + + $tokenLen = strlen($token); + for ($i = 0; $i < $tokenLen; $i++) { + if ($token[$i] == "'" && ($token[$i - 1] ?? false) !== '\\') { + $singleQuotes++; + } + if ($token[$i] == '"' && ($token[$i - 1] ?? false) !== '\\') { + $doubleQuotes++; + } + } + + $buffer .= $token; + + // Detect BEGIN with optional label + if (preg_match('/(^|\s|[)])\bBEGIN\b/i', $token)) { + $blockDepth++; + } + + // Detect END with optional label + if (preg_match('/\bEND\b(\s+[a-zA-Z0-9_]+)?\s*$/i', $token)) { + $blockDepth--; + } + + // we're somewhere inside qoutes + if (($singleQuotes % 2) != 0 || ($doubleQuotes % 2) != 0) { + continue; + } + + // If we are outside a block and encounter a semicolon, split the query + if ($blockDepth === 0 && $token === ';') { + $trimmedQuery = trim($buffer); + if ($trimmedQuery !== ';') { // Ignore empty queries + $queries[] = $trimmedQuery; + } + $buffer = ''; + $singleQuotes = 0; + $doubleQuotes = 0; + } + } + + // Add any remaining buffer as a query + if (!empty(trim($buffer))) { + $queries[] = trim($buffer); + } + + return $queries; + } + + public static function parsePgConnString(string $conninfo): array + { + $result = []; + $length = strlen($conninfo); + $i = 0; + + while ($i < $length) { + // Skip leading whitespace + while ($i < $length && ctype_space($conninfo[$i])) { + $i++; + } + + // Read the key until '=' or whitespace + $key = ''; + while ($i < $length && $conninfo[$i] !== '=' && !ctype_space($conninfo[$i])) { + $key .= $conninfo[$i++]; + } + + // Skip whitespace before '=' + while ($i < $length && ctype_space($conninfo[$i])) { + $i++; + } + + // Expect '=' after key + if ($i >= $length || $conninfo[$i] !== '=') { + // throw new \InvalidArgumentException("Expected '=' after key '$key'"); + //TODO verify - it might be socket name + } + $i++; // Move past '=' + + // Skip whitespace after '=' + while ($i < $length && ctype_space($conninfo[$i])) { + $i++; + } + + $value = ''; + + // Handle quoted value (single or double quotes) + if ($i < $length && ($conninfo[$i] === '\'' || $conninfo[$i] === '"')) { + $quote = $conninfo[$i++]; + while ($i < $length) { + if ($conninfo[$i] === '\\' && $i + 1 < $length) { + // Handle escaped character + $value .= $conninfo[++$i]; + } elseif ($conninfo[$i] === $quote) { + // End of quoted value + $i++; + + break; + } else { + $value .= $conninfo[$i++]; + } + } + } else { + // Handle unquoted value + while ($i < $length && !ctype_space($conninfo[$i])) { + if ($conninfo[$i] === '\\' && $i + 1 < $length) { + // Handle escaped character + $value .= $conninfo[++$i]; + } else { + $value .= $conninfo[$i++]; + } + } + } + + // Only store non-empty keys + if ($key !== '') { + $result[$key] = $value; + } + } + + return $result; + } + + public static function parseAttributesFromConnectionString(string $connectionString) + { + $connectionData = self::parsePgConnString($connectionString); + + $addr = $connectionData['host'] ?? $connectionData['hostaddr'] ?? null; + $attributes = []; + $attributes[TraceAttributes::SERVER_ADDRESS] = $addr; + $attributes[TraceAttributes::SERVER_PORT] = $addr !== null ? ($connectionData['port'] ?? null) : null; + $attributes[TraceAttributes::DB_NAMESPACE] = $connectionData['dbname'] ?? $connectionData['user'] ?? null; + $attributes[TraceAttributes::DB_SYSTEM_NAME] = 'postgresql'; + + return $attributes; + } + +} diff --git a/src/Instrumentation/PostgreSql/src/PostgreSqlInstrumentation.php b/src/Instrumentation/PostgreSql/src/PostgreSqlInstrumentation.php new file mode 100644 index 000000000..5fd030681 --- /dev/null +++ b/src/Instrumentation/PostgreSql/src/PostgreSqlInstrumentation.php @@ -0,0 +1,615 @@ +url(), + ); + + $tracker = new PgSqlTracker(); + + hook( + null, + 'pg_connect', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::connectPreHook('pg_connect', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::connectPostHook($instrumentation, $tracker, ...$args); + } + ); + hook( + null, + 'pg_pconnect', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::connectPreHook('pg_pconnect', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::connectPostHook($instrumentation, $tracker, ...$args); + } + ); + hook( + null, + 'pg_convert', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_convert', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::tableOperationsPostHook($instrumentation, $tracker, true, null, ...$args); + } + ); + + hook( + null, + 'pg_copy_from', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_copy_from', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::tableOperationsPostHook($instrumentation, $tracker, false, null, ...$args); + } + ); + + hook( + null, + 'pg_copy_to', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_copy_to', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::tableOperationsPostHook($instrumentation, $tracker, false, null, ...$args); + } + ); + + hook( + null, + 'pg_delete', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_delete', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::tableOperationsPostHook($instrumentation, $tracker, false, 'DELETE', ...$args); + } + ); + + hook( + null, + 'pg_prepare', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_prepare', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::preparePostHook($instrumentation, $tracker, false, ...$args); + } + ); + + hook( + null, + 'pg_execute', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_execute', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::executePostHook($instrumentation, $tracker, false, ...$args); + } + ); + + hook( + null, + 'pg_query', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_query', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::queryPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_select', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_select', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::selectPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_send_prepare', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_send_prepare', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::preparePostHook($instrumentation, $tracker, true, ...$args); + } + ); + + hook( + null, + 'pg_send_execute', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_send_execute', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::executePostHook($instrumentation, $tracker, true, ...$args); + } + ); + hook( + null, + 'pg_send_query', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_send_query', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::sendQueryPostHook($instrumentation, $tracker, ...$args); + } + ); + hook( + null, + 'pg_send_query_params', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_send_query_params', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::sendQueryParamsPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_get_result', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_get_result', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::getResultPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_lo_open', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_lo_open', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::loOpenPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_lo_write', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_lo_write', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::loWritePostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_lo_read', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_lo_read', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::loReadPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_lo_read_all', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_lo_read_all', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::loReadAllPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_lo_unlink', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_lo_unlink', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::loUnlinkPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'pg_lo_import', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_lo_import', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::loImportExportPostHook($instrumentation, $tracker, 'IMPORT', ...$args); + } + ); + + hook( + null, + 'pg_lo_export', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::basicPreHook('pg_lo_export', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::loImportExportPostHook($instrumentation, $tracker, 'EXPORT', ...$args); + } + ); + } + + /** @param non-empty-string $spanName */ + private static function connectPreHook(string $spanName, CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + $attributes = PgSqlTracker::parseAttributesFromConnectionString($params[0]); + self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, $attributes); + } + + private static function connectPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + if ($retVal instanceof Connection) { + $tracker->storeConnectionAttributes($retVal, $params[0]); + } + self::endSpan([], $exception, $retVal == false ? 'Connection error' : null); + } + + /** @param non-empty-string $spanName */ + private static function basicPreHook(string $spanName, CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + } + + private static function tableOperationsPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, bool $dropIfNoError, ?string $operationName, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $connection = $params[0]; + $attributes = null; + if ($connection instanceof Connection) { + $attributes = $tracker->getConnectionAttributes($connection); + $attributes[TraceAttributes::DB_COLLECTION_NAME] = mb_convert_encoding($params[1], 'UTF-8'); + if ($operationName) { + $attributes[TraceAttributes::DB_OPERATION_NAME] = $operationName; + } + } + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + if ($dropIfNoError && $errorStatus === null && $exception === null) { + self::dropSpan(); + + return; + } + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function preparePostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, bool $async, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + + $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($params[2], 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($params[2]); + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + + if ($retVal != false) { + $tracker->addConnectionStatement($params[0], $params[1], $params[2]); + + if ($async) { + $tracker->addAsyncLinkForConnection($params[0], Span::getCurrent()->getContext()); + } + + } + + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function sendQueryParamsPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + + $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($params[1], 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($params[1]); + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + + if ($retVal != false) { + $tracker->addAsyncLinkForConnection($params[0], Span::getCurrent()->getContext()); + } + + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function getResultPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + + if ($retVal !== false) { + if ($linkedContext = $tracker->getAsyncLinkForConnection($params[0])) { + Span::getCurrent()->addLink($linkedContext); + } + self::endSpan($attributes, $exception, null); + } else { + // pg_get_result() returns false when there are no more pending results. + // This is normal and expected behavior — it is designed for polling. + // A false return value simply means there are no results currently available. + // There’s no point in creating a span that won’t be linked to any operation. + self::dropSpan(); + } + + } + + private static function executePostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, bool $async, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + + $query = $tracker->getStatementQuery($params[0], $params[1]); + if ($query !== null) { + $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($query, 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($query); + } + + if ($retVal != false) { + if ($async) { + $tracker->addAsyncLinkForConnection($params[0], Span::getCurrent()->getContext()); + } + } + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function sendQueryPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + + $queries = PgSqlTracker::splitQueries($params[1]); + $queriesCount = count($queries); + for ($i = 0; $i < $queriesCount; $i++) { + $tracker->addAsyncLinkForConnection($params[0], Span::getCurrent()->getContext()); + } + + $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($params[1], 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($params[1]); + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function queryPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + + $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($params[1], 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($params[1]); + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function selectPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + + if ($retVal != false) { + $table = $params[1]; + $conditions = $params[2]; + $query = null; + + if (empty($conditions)) { + if (PHP_VERSION_ID >= 80400) { + $query = "SELECT * FROM {$table}"; + } else { + $query = null; + } + } else { + $where = implode(' AND ', array_map( + fn (string $k, $v) => null === $v ? $k . ' IS NULL' : $k . " = '$v'", + array_keys($conditions), + $conditions + )); + $query = "SELECT * FROM {$table} WHERE {$where}"; + } + + if ($query) { + $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($query, 'UTF-8'); + } + $attributes[TraceAttributes::DB_COLLECTION_NAME] = mb_convert_encoding($table, 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = 'SELECT'; + } + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function loOpenPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + $attributes[TraceAttributes::DB_OPERATION_NAME] = 'OPEN'; + + if ($retVal instanceof Lob) { + $tracker->trackConnectionFromLob($params[0], $retVal); + } + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function loWritePostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = []; + $lob = $params[0]; + if ($lob instanceof Lob) { + if ($connection = $tracker->getConnectionFromLob($lob)) { + $attributes = $tracker->getConnectionAttributes($connection); + } + if ($retVal !== false) { + $attributes['db.postgres.bytes_written'] = $retVal; + } + } + + $attributes[TraceAttributes::DB_OPERATION_NAME] = 'WRITE'; + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function loReadPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = []; + $lob = $params[0]; + if ($lob instanceof Lob) { + if ($connection = $tracker->getConnectionFromLob($lob)) { + $attributes = $tracker->getConnectionAttributes($connection); + } + } + $attributes[TraceAttributes::DB_OPERATION_NAME] = 'READ'; + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function loReadAllPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = []; + + $lob = $params[0]; + if ($lob instanceof Lob) { + if ($connection = $tracker->getConnectionFromLob($lob)) { + $attributes = $tracker->getConnectionAttributes($connection); + } + if ($retVal !== false) { + $attributes['db.postgres.bytes_read'] = $retVal; + } + } + $attributes[TraceAttributes::DB_OPERATION_NAME] = 'READ'; + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function loUnlinkPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + $attributes[TraceAttributes::DB_OPERATION_NAME] = 'DELETE'; + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function loImportExportPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, string $operation, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $attributes = $tracker->getConnectionAttributes($params[0]); + $attributes[TraceAttributes::DB_OPERATION_NAME] = $operation; + + $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + /** @param non-empty-string $spanName */ + private static function startSpan(string $spanName, CachedInstrumentation $instrumentation, ?string $class, ?string $function, ?string $filename, ?int $lineno, iterable $attributes) : SpanInterface + { + $parent = Context::getCurrent(); + $builder = $instrumentation->tracer() + ->spanBuilder($spanName) + ->setParent($parent) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, ($class ? $class . '::' : '') . $function) + ->setAttribute(TraceAttributes::CODE_FILE_PATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno) + ->setAttribute(TraceAttributes::DB_SYSTEM_NAME, 'postgresql') + // @phan-suppress-next-line PhanDeprecatedClassConstant + ->setAttribute(TraceAttributes::DB_SYSTEM, 'postgresql') + ->setAttributes($attributes); + + $span = $builder->startSpan(); + $context = $span->storeInContext($parent); + + Context::storage()->attach($context); + + return $span; + } + + private static function endSpan(?iterable $attributes, ?\Throwable $exception, ?string $errorStatus) + { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + $scope->detach(); + $span = Span::fromContext($scope->context()); + + if ($attributes) { + $span->setAttributes($attributes); + } + + if ($errorStatus !== null) { + $span->setAttribute(TraceAttributes::EXCEPTION_MESSAGE, $errorStatus); + $span->setStatus(StatusCode::STATUS_ERROR, $errorStatus); + } + + if ($exception) { + $span->recordException($exception); + $span->setAttribute(TraceAttributes::EXCEPTION_TYPE, $exception::class); + $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); + } + + $span->end(); + } + + private static function dropSpan() + { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + $scope->detach(); + } + + private static function extractQueryCommand($query) : ?string + { + $query = preg_replace("/\r\n|\n\r|\r/", "\n", $query); + /** @psalm-suppress PossiblyInvalidArgument */ + if (preg_match('/^\s*(?:--[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*([a-zA-Z_][a-zA-Z0-9_]*)/i', $query, $matches)) { + return strtoupper($matches[1]); + } + + return null; + } + +} diff --git a/src/Instrumentation/PostgreSql/tests/Integration/PgSqlTrackerTest.php b/src/Instrumentation/PostgreSql/tests/Integration/PgSqlTrackerTest.php new file mode 100644 index 000000000..63bb1dd03 --- /dev/null +++ b/src/Instrumentation/PostgreSql/tests/Integration/PgSqlTrackerTest.php @@ -0,0 +1,119 @@ +assertSame(['SELECT 1;', 'SELECT 2;'], $queries); + } + + public function test_parse_standard_connection_string(): void + { + $result = PgSqlTracker::parsePgConnString('host=localhost port=5432 dbname=mydb user=otel password=secret'); + + $this->assertSame('localhost', $result['host']); + $this->assertSame('5432', $result['port']); + $this->assertSame('mydb', $result['dbname']); + $this->assertSame('otel', $result['user']); + $this->assertSame('secret', $result['password']); + } + + public function test_parse_quoted_values(): void + { + $result = PgSqlTracker::parsePgConnString("host='localhost' dbname=\"my db\" user='user name'"); + + $this->assertSame('localhost', $result['host']); + $this->assertSame('my db', $result['dbname']); + $this->assertSame('user name', $result['user']); + } + public function test_parse_socket_only(): void + { + $result = PgSqlTracker::parsePgConnString('dbname=mydb user=postgres'); + + $this->assertArrayNotHasKey('host', $result); + $this->assertSame('mydb', $result['dbname']); + $this->assertSame('postgres', $result['user']); + } + + public function test_parse_empty_string(): void + { + $result = PgSqlTracker::parsePgConnString(''); + $this->assertSame([], $result); + } + + public function test_parse_attributes_from_connstring_with_host(): void + { + $connString = 'host=localhost port=5432 dbname=testdb user=otel'; + $attrs = PgSqlTracker::parseAttributesFromConnectionString($connString); + + $this->assertSame('localhost', $attrs['server.address']); + $this->assertSame('5432', $attrs['server.port']); + $this->assertSame('testdb', $attrs['db.namespace']); + $this->assertSame('postgresql', $attrs['db.system.name']); + } + + public function test_parse_attributes_from_connstring_socket(): void + { + $connString = 'dbname=testdb user=otel'; + $attrs = PgSqlTracker::parseAttributesFromConnectionString($connString); + + $this->assertNull($attrs['server.address']); + $this->assertSame('testdb', $attrs['db.namespace']); + $this->assertSame('postgresql', $attrs['db.system.name']); + } + + public function test_basic_split(): void + { + $sql = "SELECT * FROM users; INSERT INTO logs (message) VALUES ('test');"; + $expected = [ + 'SELECT * FROM users;', + "INSERT INTO logs (message) VALUES ('test');", + ]; + + $result = PgSqlTracker::splitQueries($sql); + $this->assertEquals($expected, $result); + } + + public function test_whitespace_variants(): void + { + $sql = " SELECT 1;\n\nINSERT INTO test VALUES (2); SELECT 3"; + $expected = [ + 'SELECT 1;', + 'INSERT INTO test VALUES (2);', + 'SELECT 3', + ]; + + $result = PgSqlTracker::splitQueries($sql); + $this->assertEquals($expected, $result); + } + + public function test_semicolon_in_quotes(): void + { + $sql = "INSERT INTO x (text) VALUES ('abc;def'); SELECT 1;"; + $expected = [ + "INSERT INTO x (text) VALUES ('abc;def');", + 'SELECT 1;', + ]; + + $result = PgSqlTracker::splitQueries($sql); + $this->assertEquals($expected, $result); + } + public function test_empty_input(): void + { + $sql = "\n\n\t "; + $expected = []; + $result = PgSqlTracker::splitQueries($sql); + $this->assertEquals($expected, $result); + } + +} diff --git a/src/Instrumentation/PostgreSql/tests/Integration/PostgreSqlInstrumentationTest.php b/src/Instrumentation/PostgreSql/tests/Integration/PostgreSqlInstrumentationTest.php new file mode 100644 index 000000000..ce19347d2 --- /dev/null +++ b/src/Instrumentation/PostgreSql/tests/Integration/PostgreSqlInstrumentationTest.php @@ -0,0 +1,601 @@ + */ + private ArrayObject $storage; + + private string $pgsqlHost; + + private string $user; + private string $passwd; + private string $database; + + public function setUp(): void + { + $this->storage = new ArrayObject(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + + $this->scope = Configurator::create() + ->withTracerProvider($tracerProvider) + ->withPropagator(TraceContextPropagator::getInstance()) + ->activate(); + + $this->pgsqlHost = getenv('POSTGRESQL_HOST') ?: '127.0.0.1'; + + $this->user = 'otel_user'; + $this->passwd = 'otel_passwd'; + $this->database = 'otel_db'; + } + + public function tearDown(): void + { + $this->scope->detach(); + } + + private function assertDatabaseAttributes(int $offset) + { + $span = $this->storage->offsetGet($offset); + $this->assertEquals($this->pgsqlHost, $span->getAttributes()->get(TraceAttributes::SERVER_ADDRESS)); + $this->assertEquals($this->database, $span->getAttributes()->get(TraceAttributes::DB_NAMESPACE)); + $this->assertEquals('postgresql', $span->getAttributes()->get(TraceAttributes::DB_SYSTEM_NAME)); + } + + private function assertDatabaseAttributesForAllSpans(int $offsets) + { + for ($offset = 0; $offset < $offsets; $offset++) { + $this->assertDatabaseAttributes($offset); + } + } + + private function assertAttributes(int $offset, iterable $attributes) + { + foreach ($attributes as $attribute => $expected) { + $this->assertSame($expected, $this->storage->offsetGet($offset)->getAttributes()->get($attribute)); + } + } + public function test_pg_connect(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + pg_close($conn); + $conn = pg_pconnect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + pg_close($conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset++)->getName()); + $this->assertSame('pg_pconnect', $this->storage->offsetGet($offset++)->getName()); + + $this->assertCount($offset, $this->storage); + + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_query(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertTrue($conn instanceof Connection); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $res = pg_query($conn, 'SELECT * FROM users'); + $this->assertTrue($res instanceof Result); + + $this->assertSame('pg_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_QUERY_TEXT => 'SELECT * FROM users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + while ($row = pg_fetch_assoc($res)) { + } + $offset++; + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_convert(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $data = [ + 'name' => 'Foo Bar', + 'email' => 'foo.bar@example.com', + 'created_at' => '2025-01-01 12:00:00', + ]; + + $converted = pg_convert($conn, 'users', $data); + $this->assertIsArray($converted); + // no span from success operation - we're captuing failures only for pg_convert + + $data = [ + 'data' => 'data', + ]; + + $converted = @pg_convert($conn, 'users', $data); + $this->assertFalse($converted); + + $this->assertSame('pg_convert', actual: $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_COLLECTION_NAME => 'users', + ]); + $this->assertSame(StatusCode::STATUS_ERROR, $this->storage->offsetGet($offset)->getStatus()->getCode()); + + $offset++; + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_copy_from(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $data = [ + "2000\tAlice Test\talice2000@example.com\t2025-07-01 10:00:00", + "2001\tBob Test\tbob2001@example.com\t2025-07-01 10:05:00", + ]; + + $result = pg_copy_from($conn, 'users', $data, "\t"); + $this->assertTrue($result); + + $this->assertSame('pg_copy_from', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_COLLECTION_NAME => 'users', + ]); + $offset++; + + $del = pg_query($conn, 'DELETE FROM users WHERE id IN (2000, 2001)'); + $this->assertTrue($del !== false); + + $this->assertSame('pg_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_QUERY_TEXT => 'DELETE FROM users WHERE id IN (2000, 2001)', + TraceAttributes::DB_OPERATION_NAME => 'DELETE', + ]); + $offset++; + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_copy_to(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $rows = pg_copy_to($conn, 'users', "\t"); + $this->assertIsArray($rows); + $this->assertNotEmpty($rows); + + $this->assertSame('pg_copy_to', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_COLLECTION_NAME => 'users', + ]); + $offset++; + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_delete(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + // Insert test row to delete + $res = pg_query($conn, "INSERT INTO users (id, name, email, created_at) VALUES (3000, 'Delete Me', 'delete@example.com', now())"); + $this->assertTrue($res !== false); + + $this->assertSame('pg_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + $offset++; + + // pg_delete uses associative condition array + $res = pg_delete($conn, 'users', ['id' => 3000]); + $this->assertTrue($res !== false); + + $this->assertSame('pg_delete', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'DELETE', + TraceAttributes::DB_COLLECTION_NAME => 'users', + ]); + $offset++; + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_prepare_and_execute(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $prepare = pg_prepare($conn, 'select_user_stmt', 'SELECT * FROM users WHERE email = $1'); + $this->assertInstanceOf(Result::class, $prepare); + + $this->assertSame('pg_prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::DB_QUERY_TEXT => 'SELECT * FROM users WHERE email = $1', + ]); + $offset++; + + $execute = pg_execute($conn, 'select_user_stmt', ['john.doe@example.com']); + $this->assertInstanceOf(Result::class, $execute); + + $this->assertSame('pg_execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::DB_QUERY_TEXT => 'SELECT * FROM users WHERE email = $1', + ]); + $offset++; + + $row = pg_fetch_assoc($execute); + $this->assertNotEmpty($row); + $this->assertSame('John Doe', $row['name']); + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_select(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $conditions = [ + 'name' => 'Jane Smith', + 'email' => 'jane.smith@example.com', + ]; + $result = pg_select($conn, 'users', $conditions); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + + $this->assertSame('pg_select', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::DB_COLLECTION_NAME => 'users', + TraceAttributes::DB_QUERY_TEXT => "SELECT * FROM users WHERE name = 'Jane Smith' AND email = 'jane.smith@example.com'", + ]); + $offset++; + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_send_prepare_and_execute(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $sent = pg_send_prepare($conn, 'async_select_user', 'SELECT * FROM users WHERE email = $1'); + $this->assertTrue($sent); + + $this->assertSame('pg_send_prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::DB_QUERY_TEXT => 'SELECT * FROM users WHERE email = $1', + ]); + $offset++; + + $prepareResult = pg_get_result($conn); + $this->assertInstanceOf(Result::class, $prepareResult); + $this->assertSame(PGSQL_COMMAND_OK, pg_result_status($prepareResult)); + + $this->assertSame('pg_get_result', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $sent = pg_send_execute($conn, 'async_select_user', ['bob.johnson@example.com']); + $this->assertTrue($sent); + + $this->assertSame('pg_send_execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::DB_QUERY_TEXT => 'SELECT * FROM users WHERE email = $1', + ]); + $offset++; + + $executeResult = pg_get_result($conn); + $this->assertInstanceOf(Result::class, $executeResult); + + $this->assertSame('pg_get_result', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $row = pg_fetch_assoc($executeResult); + $this->assertIsArray($row); + $this->assertSame('Bob Johnson', $row['name']); + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_send_query(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $sent = pg_send_query($conn, 'SELECT name FROM users WHERE email = \'john.doe@example.com\''); + $this->assertTrue($sent); + + $this->assertSame('pg_send_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_QUERY_TEXT => 'SELECT name FROM users WHERE email = \'john.doe@example.com\'', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + $offset++; + + $result = pg_get_result($conn); + $this->assertInstanceOf(Result::class, $result); + + $this->assertSame('pg_get_result', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $row = pg_fetch_assoc($result); + $this->assertNotEmpty($row); + $this->assertSame('John Doe', $row['name']); + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_send_query_params(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $sent = pg_send_query_params( + $conn, + 'SELECT name FROM users WHERE email = $1', + ['jane.smith@example.com'] + ); + $this->assertTrue($sent); + + $this->assertSame('pg_send_query_params', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_QUERY_TEXT => 'SELECT name FROM users WHERE email = $1', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + $offset++; + + $result = pg_get_result($conn); + $this->assertInstanceOf(Result::class, $result); + + $this->assertSame('pg_get_result', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $row = pg_fetch_assoc($result); + $this->assertNotEmpty($row); + $this->assertSame('Jane Smith', $row['name']); + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_lo_read_write_unlink(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + // BEGIN + pg_query($conn, 'BEGIN'); + $this->assertSame('pg_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_QUERY_TEXT => 'BEGIN', + TraceAttributes::DB_OPERATION_NAME => 'BEGIN', + ]); + $offset++; + + // Create new large object + $oid = pg_lo_create($conn); + $this->assertIsInt($oid); + + $fd = pg_lo_open($conn, $oid, 'w'); + $this->assertInstanceOf(Lob::class, $fd); + $this->assertSame('pg_lo_open', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'OPEN', + ]); + $offset++; + + $written = pg_lo_write($fd, "Hello Postgres LOB\n"); + $this->assertGreaterThan(0, $written); + $this->assertSame('pg_lo_write', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'WRITE', + 'db.postgres.bytes_written' => 19, + ]); + $offset++; + + pg_lo_seek($fd, 0, SEEK_SET); + $data = pg_lo_read($fd, 1024); + $this->assertIsString($data); + $this->assertSame("Hello Postgres LOB\n", $data); + $this->assertSame('pg_lo_read', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'READ', + ]); + $offset++; + + pg_lo_seek($fd, 0, SEEK_SET); + ob_start(); + $readBytes = pg_lo_read_all($fd); + $output = ob_get_clean(); + $this->assertSame("Hello Postgres LOB\n", $output); + $this->assertSame('pg_lo_read_all', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'READ', + 'db.postgres.bytes_read' => 19, + ]); + $offset++; + + pg_lo_close($fd); + + $this->assertTrue(pg_lo_unlink($conn, $oid)); + $this->assertSame('pg_lo_unlink', $this->storage->offsetGet($offset)->getName()); + $offset++; + + pg_query($conn, 'COMMIT'); + $this->assertSame('pg_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_QUERY_TEXT => 'COMMIT', + TraceAttributes::DB_OPERATION_NAME => 'COMMIT', + ]); + $offset++; + + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_pg_lo_import_and_export(): void + { + $conn = pg_connect('host=' . $this->pgsqlHost . ' dbname=' . $this->database . ' user=' . $this->user . ' password=' . $this->passwd); + $this->assertInstanceOf(Connection::class, $conn); + + $offset = 0; + $this->assertSame('pg_connect', $this->storage->offsetGet($offset)->getName()); + $offset++; + + pg_query($conn, 'BEGIN'); + $this->assertSame('pg_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_QUERY_TEXT => 'BEGIN', + TraceAttributes::DB_OPERATION_NAME => 'BEGIN', + ]); + $offset++; + + $inputPath = tempnam(sys_get_temp_dir(), 'pg-in-'); + $this->assertIsString($inputPath); + $expectedContent = "LOB FILE CONTENT\n"; + file_put_contents($inputPath, $expectedContent); + + $oid = pg_lo_import($conn, $inputPath); + $this->assertIsInt($oid); + $this->assertSame('pg_lo_import', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $outputPath = tempnam(sys_get_temp_dir(), 'pg-out-'); + $this->assertIsString($outputPath); + $this->assertTrue(pg_lo_export($conn, $oid, $outputPath)); + $this->assertSame('pg_lo_export', $this->storage->offsetGet($offset)->getName()); + $offset++; + + $actualContent = file_get_contents($outputPath); + $this->assertSame($expectedContent, $actualContent); + + $this->assertTrue(pg_lo_unlink($conn, $oid)); + $this->assertSame('pg_lo_unlink', $this->storage->offsetGet($offset)->getName()); + $offset++; + + pg_query($conn, 'COMMIT'); + $this->assertSame('pg_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_QUERY_TEXT => 'COMMIT', + TraceAttributes::DB_OPERATION_NAME => 'COMMIT', + ]); + $offset++; + + @unlink($inputPath); + @unlink($outputPath); + pg_close($conn); + + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + +} diff --git a/src/Instrumentation/PostgreSql/tests/Unit/.gitkeep b/src/Instrumentation/PostgreSql/tests/Unit/.gitkeep new file mode 100644 index 000000000..e69de29bb