├── .project.yaml ├── .gitignore ├── tests ├── mysql │ ├── Makefile │ ├── .gitignore │ ├── conf.d │ │ └── my.cnf │ ├── go.mod │ ├── docker-compose.yml │ └── go.sum ├── postgres │ ├── Makefile │ ├── .gitignore │ ├── go.mod │ ├── docker-compose.yml │ └── go.sum ├── sqlite3 │ ├── Makefile │ ├── .gitignore │ ├── go.mod │ ├── go.sum │ └── duration_option_test.go ├── testhelper │ ├── go.mod │ ├── seq_id_gen.go │ ├── go.sum │ ├── logs_assertion.go │ └── step_event_msg_options.go ├── Makefile └── test.mk ├── examples ├── logs-mysql │ ├── Makefile │ ├── conf.d │ │ └── my.cnf │ ├── go.mod │ ├── go.sum │ ├── docker-compose.yml │ ├── main.go │ └── results │ │ ├── debug-log.txt │ │ └── info-log.txt ├── logs-sqlite3 │ ├── Makefile │ ├── go.sum │ ├── go.mod │ ├── results │ │ ├── info-log.txt │ │ ├── debug-log.txt │ │ ├── trace-log.txt │ │ ├── verbose-log.txt │ │ └── verbose-log.json │ └── main.go ├── logs-postgres │ ├── Makefile │ ├── go.sum │ ├── go.mod │ ├── docker-compose.yml │ ├── results │ │ ├── info-log.txt │ │ ├── debug-log.txt │ │ ├── trace-log.txt │ │ ├── verbose-log.txt │ │ └── verbose-log.json │ └── main.go ├── with-sqlc │ ├── schema.sql │ ├── Makefile │ ├── sqlc.yaml │ ├── tutorial │ │ ├── models.go │ │ ├── db.go │ │ └── query.sql.go │ ├── query.sql │ ├── go.mod │ ├── tutorial.go │ └── go.sum ├── with-go-requestid │ ├── go.mod │ ├── run.sh │ ├── Makefile │ ├── go.sum │ ├── main.go │ └── handlers.go ├── Makefile └── logs.mk ├── go.mod ├── .github ├── codecov.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql.yml ├── dsn_connector.go ├── event.go ├── .golangci.yml ├── mock_driver_test.go ├── event_test.go ├── dsn_connector_test.go ├── options_test.go ├── open_example_test.go ├── tx.go ├── LICENSE ├── options_example_test.go ├── duration.go ├── open.go ├── step.go ├── id_gen_example_test.go ├── open_test.go ├── duration_test.go ├── step_options_test.go ├── stmt_args.go ├── level.go ├── step_options.go ├── connector_test.go ├── stmt_args_test.go ├── driver_test.go ├── connector.go ├── factory.go ├── slog_example_test.go ├── step_logger_test.go ├── id_gen.go ├── rows_test.go ├── doc.go ├── step_logger.go ├── Makefile ├── slog_test.go ├── stmt_test.go ├── driver.go ├── slog.go ├── level_test.go ├── id_gen_test.go ├── options.go ├── rows.go ├── conn_test.go └── README.md /.project.yaml: -------------------------------------------------------------------------------- 1 | linters: 99 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | coverage.* -------------------------------------------------------------------------------- /tests/mysql/Makefile: -------------------------------------------------------------------------------- 1 | include ../test.mk 2 | -------------------------------------------------------------------------------- /tests/postgres/Makefile: -------------------------------------------------------------------------------- 1 | include ../test.mk 2 | -------------------------------------------------------------------------------- /tests/sqlite3/Makefile: -------------------------------------------------------------------------------- 1 | include ../test.mk 2 | -------------------------------------------------------------------------------- /examples/logs-mysql/Makefile: -------------------------------------------------------------------------------- 1 | include ../logs.mk 2 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/Makefile: -------------------------------------------------------------------------------- 1 | include ../logs.mk 2 | -------------------------------------------------------------------------------- /tests/mysql/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | coverage.* 3 | -------------------------------------------------------------------------------- /tests/sqlite3/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | coverage.* 3 | -------------------------------------------------------------------------------- /examples/logs-postgres/Makefile: -------------------------------------------------------------------------------- 1 | include ../logs.mk 2 | -------------------------------------------------------------------------------- /tests/postgres/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | coverage.* 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/akm/sql-slog 2 | 3 | go 1.22.10 4 | -------------------------------------------------------------------------------- /examples/with-sqlc/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE authors ( 2 | id INTEGER PRIMARY KEY, 3 | name text NOT NULL, 4 | bio text 5 | ); 6 | -------------------------------------------------------------------------------- /tests/mysql/conf.d/my.cnf: -------------------------------------------------------------------------------- 1 | [mysqld] 2 | character-set-server=utf8mb4 3 | general-log-file=/var/log/mysql/mysqld.log 4 | 5 | [client] 6 | default-character-set=utf8mb4 7 | -------------------------------------------------------------------------------- /examples/logs-mysql/conf.d/my.cnf: -------------------------------------------------------------------------------- 1 | [mysqld] 2 | character-set-server=utf8mb4 3 | general-log-file=/var/log/mysql/mysqld.log 4 | 5 | [client] 6 | default-character-set=utf8mb4 7 | -------------------------------------------------------------------------------- /examples/with-sqlc/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default 2 | default: run 3 | 4 | .PHONY: build 5 | build: 6 | go build ./... 7 | 8 | .PHONY: run 9 | run: build 10 | go run . 11 | -------------------------------------------------------------------------------- /examples/logs-postgres/go.sum: -------------------------------------------------------------------------------- 1 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 2 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 3 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 2 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 3 | -------------------------------------------------------------------------------- /examples/with-sqlc/sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | sql: 3 | - engine: "sqlite" 4 | queries: "query.sql" 5 | schema: "schema.sql" 6 | gen: 7 | go: 8 | package: "tutorial" 9 | out: "tutorial" 10 | -------------------------------------------------------------------------------- /examples/logs-postgres/go.mod: -------------------------------------------------------------------------------- 1 | module postgres-examples 2 | 3 | go 1.23.2 4 | 5 | replace github.com/akm/sql-slog => ../.. 6 | 7 | require ( 8 | github.com/akm/sql-slog v0.0.0-00010101000000-000000000000 9 | github.com/lib/pq v1.10.9 10 | ) 11 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/go.mod: -------------------------------------------------------------------------------- 1 | module sqlite3-examples 2 | 3 | go 1.23.2 4 | 5 | replace github.com/akm/sql-slog => ../.. 6 | 7 | require ( 8 | github.com/akm/sql-slog v0.0.0-00010101000000-000000000000 9 | github.com/mattn/go-sqlite3 v1.14.24 10 | ) 11 | -------------------------------------------------------------------------------- /examples/with-sqlc/tutorial/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | 5 | package tutorial 6 | 7 | import ( 8 | "database/sql" 9 | ) 10 | 11 | type Author struct { 12 | ID int64 13 | Name string 14 | Bio sql.NullString 15 | } 16 | -------------------------------------------------------------------------------- /examples/logs-mysql/go.mod: -------------------------------------------------------------------------------- 1 | module mysql-examples 2 | 3 | go 1.23.2 4 | 5 | replace github.com/akm/sql-slog => ../.. 6 | 7 | require ( 8 | github.com/akm/sql-slog v0.0.0-00010101000000-000000000000 9 | github.com/go-sql-driver/mysql v1.8.1 10 | ) 11 | 12 | require filippo.io/edwards25519 v1.1.0 // indirect 13 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | # https://docs.codecov.com/docs/codecovyml-reference#ignore 2 | # https://zenn.dev/ncdc/articles/codecov_yml_settings 3 | 4 | # https://docs.codecov.com/docs/commit-status 5 | coverage: 6 | status: 7 | project: 8 | default: # default is the status check's name, not default settings 9 | target: 100% 10 | -------------------------------------------------------------------------------- /examples/with-go-requestid/go.mod: -------------------------------------------------------------------------------- 1 | module example-with-go-requestid 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/akm/go-requestid v0.3.1 7 | github.com/akm/sql-slog v0.0.0-00010101000000-000000000000 8 | github.com/mattn/go-sqlite3 v1.14.24 9 | ) 10 | 11 | require github.com/akm/slogctx v0.5.1 // indirect 12 | 13 | replace github.com/akm/sql-slog => ../.. 14 | -------------------------------------------------------------------------------- /examples/logs-mysql/go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 4 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 5 | -------------------------------------------------------------------------------- /examples/with-go-requestid/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | 5 | go build -o testsrv . 6 | ./testsrv > server-logs.txt & 7 | 8 | SERVER_PID=$! 9 | ps -ef | grep $SERVER_PID 10 | echo "Server started with PID $SERVER_PID" 11 | sleep 2 12 | make run-client 13 | 14 | ps -ef | grep $SERVER_PID 15 | 16 | kill -HUP $SERVER_PID 17 | echo "Server stopped." 18 | 19 | rm ./testsrv 20 | -------------------------------------------------------------------------------- /tests/testhelper/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/akm/sql-slog/tests/testhelper 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/akm/sql-slog v0.1.3 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | 16 | replace github.com/akm/sql-slog => ../.. 17 | -------------------------------------------------------------------------------- /examples/Makefile: -------------------------------------------------------------------------------- 1 | ,PHONY: logs-gen 2 | logs-gen: logs-sqlite3-gen logs-postgres-gen logs-mysql-gen 3 | 4 | .PHONY: clean 5 | clean: logs-sqlite3-clean logs-postgres-clean logs-mysql-clean 6 | 7 | .PHONY: clobber 8 | clobber: clean logs-sqlite3-clobber logs-postgres-clobber logs-mysql-clobber 9 | 10 | logs-sqlite3-%: 11 | $(MAKE) -C logs-sqlite3 $* 12 | 13 | logs-postgres-%: 14 | $(MAKE) -C logs-postgres $* 15 | 16 | logs-mysql-%: 17 | $(MAKE) -C logs-mysql $* 18 | -------------------------------------------------------------------------------- /examples/with-sqlc/query.sql: -------------------------------------------------------------------------------- 1 | -- name: GetAuthor :one 2 | SELECT * FROM authors 3 | WHERE id = ? LIMIT 1; 4 | 5 | -- name: ListAuthors :many 6 | SELECT * FROM authors 7 | ORDER BY name; 8 | 9 | -- name: CreateAuthor :one 10 | INSERT INTO authors ( 11 | name, bio 12 | ) VALUES ( 13 | ?, ? 14 | ) 15 | RETURNING *; 16 | 17 | -- name: UpdateAuthor :exec 18 | UPDATE authors 19 | set name = ?, 20 | bio = ? 21 | WHERE id = ?; 22 | 23 | -- name: DeleteAuthor :exec 24 | DELETE FROM authors 25 | WHERE id = ?; 26 | -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: run 2 | run: sqlite3-test postgres-test mysql-test 3 | 4 | .PHONY: run-with-coverage 5 | run-with-coverage: sqlite3-test-with-coverage postgres-test-with-coverage mysql-test-with-coverage 6 | 7 | .PHONY: clean 8 | clean: sqlite3-clean postgres-clean mysql-clean 9 | 10 | .PHONY: clobber 11 | clobber: sqlite3-clobber postgres-clobber mysql-clobber 12 | 13 | mysql-%: 14 | $(MAKE) -C mysql $* 15 | 16 | postgres-%: 17 | $(MAKE) -C postgres $* 18 | 19 | sqlite3-%: 20 | $(MAKE) -C sqlite3 $* 21 | -------------------------------------------------------------------------------- /dsn_connector.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | ) 7 | 8 | type dsnConnector struct { 9 | dsn string 10 | driver driver.Driver 11 | } 12 | 13 | var _ driver.Connector = (*dsnConnector)(nil) 14 | 15 | // Connect implements driver.Connector. 16 | func (t dsnConnector) Connect(_ context.Context) (driver.Conn, error) { 17 | return t.driver.Open(t.dsn) 18 | } 19 | 20 | // Driver implements driver.Connector. 21 | func (t dsnConnector) Driver() driver.Driver { 22 | return t.driver 23 | } 24 | -------------------------------------------------------------------------------- /tests/postgres/go.mod: -------------------------------------------------------------------------------- 1 | module postgres-test 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/akm/sql-slog v0.1.3 7 | github.com/akm/sql-slog/tests/testhelper v0.0.0-00010101000000-000000000000 8 | github.com/lib/pq v1.10.9 9 | github.com/stretchr/testify v1.10.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | 18 | replace github.com/akm/sql-slog => ../.. 19 | 20 | replace github.com/akm/sql-slog/tests/testhelper => ../testhelper 21 | -------------------------------------------------------------------------------- /tests/testhelper/seq_id_gen.go: -------------------------------------------------------------------------------- 1 | package testhelper 2 | 3 | import "fmt" 4 | 5 | type SeqIDGenerator struct { 6 | format string 7 | seq int 8 | } 9 | 10 | func NewSeqIDGenerator() *SeqIDGenerator { 11 | return &SeqIDGenerator{ 12 | format: "%04d", 13 | seq: 0, 14 | } 15 | } 16 | 17 | func (g *SeqIDGenerator) Generate() string { 18 | g.seq++ 19 | return fmt.Sprintf(g.format, g.seq) 20 | } 21 | 22 | func (g *SeqIDGenerator) Next() string { 23 | return fmt.Sprintf(g.format, g.seq+1) 24 | } 25 | 26 | func (g *SeqIDGenerator) Set(v int) { 27 | g.seq = v 28 | } 29 | -------------------------------------------------------------------------------- /tests/sqlite3/go.mod: -------------------------------------------------------------------------------- 1 | module sqlite3-test 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/akm/sql-slog v0.1.3 7 | github.com/akm/sql-slog/tests/testhelper v0.0.0-00010101000000-000000000000 8 | github.com/mattn/go-sqlite3 v1.14.24 9 | github.com/stretchr/testify v1.10.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | 18 | replace github.com/akm/sql-slog => ../.. 19 | 20 | replace github.com/akm/sql-slog/tests/testhelper => ../testhelper 21 | -------------------------------------------------------------------------------- /tests/mysql/go.mod: -------------------------------------------------------------------------------- 1 | module mysql-test 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/akm/sql-slog v0.1.3 7 | github.com/akm/sql-slog/tests/testhelper v0.0.0-00010101000000-000000000000 8 | github.com/go-sql-driver/mysql v1.9.3 9 | github.com/stretchr/testify v1.10.0 10 | ) 11 | 12 | require ( 13 | filippo.io/edwards25519 v1.1.0 // indirect 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | ) 18 | 19 | replace github.com/akm/sql-slog => ../.. 20 | 21 | replace github.com/akm/sql-slog/tests/testhelper => ../testhelper 22 | -------------------------------------------------------------------------------- /tests/postgres/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Variable substitution 2 | # https://docs.docker.com/compose/compose-file/compose-file-v3/#variable-substitution 3 | # 環境変数は backends/test/containers/Makefile で設定されています。 4 | name: "sql-slog-postgres-test" 5 | services: 6 | mysql: 7 | image: postgres:17.2-bookworm 8 | hostname: postgres 9 | restart: always 10 | environment: 11 | POSTGRES_DB: ${POSTGRES_DATABASE} 12 | POSTGRES_USER: root 13 | POSTGRES_PASSWORD: password 14 | TZ: "Asia/Tokyo" 15 | ports: 16 | - "${POSTGRES_PORT}:5432" 17 | networks: 18 | - network1 19 | 20 | networks: 21 | network1: 22 | -------------------------------------------------------------------------------- /examples/logs-postgres/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Variable substitution 2 | # https://docs.docker.com/compose/compose-file/compose-file-v3/#variable-substitution 3 | # 環境変数は backends/test/containers/Makefile で設定されています。 4 | name: "sql-slog-postgres-test" 5 | services: 6 | mysql: 7 | image: postgres:17.2-bookworm 8 | hostname: postgres 9 | restart: always 10 | environment: 11 | POSTGRES_DB: ${POSTGRES_DATABASE} 12 | POSTGRES_USER: root 13 | POSTGRES_PASSWORD: password 14 | TZ: "Asia/Tokyo" 15 | ports: 16 | - "${POSTGRES_PORT}:5432" 17 | networks: 18 | - network1 19 | 20 | networks: 21 | network1: 22 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | // Event is the event type of the step. 4 | type Event int 5 | 6 | const ( 7 | EventStart Event = iota + 1 // Event when the step starts. 8 | EventError // Event when the step ends with an error. 9 | EventComplete // Event when the step completes successfully. 10 | ) 11 | 12 | // String returns the string representation of the event. 13 | func (pe *Event) String() string { 14 | switch *pe { 15 | case EventStart: 16 | return "Start" 17 | case EventError: 18 | return "Error" 19 | case EventComplete: 20 | return "Complete" 21 | default: 22 | return "Unknown" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/mysql/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Variable substitution 2 | # https://docs.docker.com/compose/compose-file/compose-file-v3/#variable-substitution 3 | # 環境変数は backends/test/containers/Makefile で設定されています。 4 | name: "sql-slog-mysql-test" 5 | services: 6 | mysql: 7 | image: mysql:8.0.38 8 | hostname: mysql 9 | restart: always 10 | environment: 11 | MYSQL_DATABASE: ${MYSQL_DATABASE} 12 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 13 | MYSQL_ROOT_HOST: "%" 14 | ports: 15 | - "${MYSQL_PORT}:3306" 16 | volumes: 17 | - ./conf.d:/etc/mysql/conf.d 18 | networks: 19 | - network1 20 | 21 | networks: 22 | network1: 23 | -------------------------------------------------------------------------------- /examples/logs-mysql/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Variable substitution 2 | # https://docs.docker.com/compose/compose-file/compose-file-v3/#variable-substitution 3 | # 環境変数は backends/test/containers/Makefile で設定されています。 4 | name: "sql-slog-mysql-test" 5 | services: 6 | mysql: 7 | image: mysql:8.0.38 8 | hostname: mysql 9 | restart: always 10 | environment: 11 | MYSQL_DATABASE: ${MYSQL_DATABASE} 12 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 13 | MYSQL_ROOT_HOST: "%" 14 | ports: 15 | - "${MYSQL_PORT}:3306" 16 | volumes: 17 | - ./conf.d:/etc/mysql/conf.d 18 | networks: 19 | - network1 20 | 21 | networks: 22 | network1: 23 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable-all: true 3 | disable: 4 | - depguard 5 | - exhaustruct 6 | - gochecknoglobals 7 | - nlreturn 8 | - nolintlint 9 | - tenv # The linter 'tenv' is deprecated (since v1.64.0) due to: Duplicate feature another linter. Replaced by usetesting. 10 | - testableexamples 11 | - testpackage 12 | - varnamelen 13 | - wrapcheck 14 | - wsl 15 | 16 | issues: 17 | exclude-files: 18 | - example_test.go 19 | exclude-rules: 20 | - path: _test\.go 21 | linters: 22 | - err113 23 | - forcetypeassert 24 | - funlen 25 | linters-settings: 26 | cyclop: 27 | skip-tests: true 28 | lll: 29 | line-length: 200 30 | -------------------------------------------------------------------------------- /examples/with-go-requestid/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | go build -o /dev/null ./... 4 | 5 | .PHONY: run-server 6 | run-server: 7 | go run . 8 | 9 | .PHONY: run-client 10 | run-client: 11 | curl -i -X POST http://localhost:8080/todos --data '{"title":"List up", "status": "done"}' 12 | curl -i -X POST http://localhost:8080/todos --data '{"title":"Go shopping", "status": "pending"}' 13 | curl -i http://localhost:8080/todos 14 | curl -i http://localhost:8080/todos/1 15 | curl -i -X PUT http://localhost:8080/todos/2 --data '{"title":"Go shopping", "status": "done"}' 16 | curl -i -X DELETE http://localhost:8080/todos/1 17 | curl -i http://localhost:8080/todos 18 | 19 | .PHONY: run 20 | run: 21 | ./run.sh 22 | -------------------------------------------------------------------------------- /examples/with-sqlc/tutorial/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | 5 | package tutorial 6 | 7 | import ( 8 | "context" 9 | "database/sql" 10 | ) 11 | 12 | type DBTX interface { 13 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error) 14 | PrepareContext(context.Context, string) (*sql.Stmt, error) 15 | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) 16 | QueryRowContext(context.Context, string, ...interface{}) *sql.Row 17 | } 18 | 19 | func New(db DBTX) *Queries { 20 | return &Queries{db: db} 21 | } 22 | 23 | type Queries struct { 24 | db DBTX 25 | } 26 | 27 | func (q *Queries) WithTx(tx *sql.Tx) *Queries { 28 | return &Queries{ 29 | db: tx, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/logs.mk: -------------------------------------------------------------------------------- 1 | .PHONY: run 2 | run: 3 | @go run . ${LOG_LEVEL} ${LOG_FORMAT} 4 | 5 | RESULTS_DIR=results 6 | $(RESULTS_DIR): 7 | mkdir -p $(RESULTS_DIR) 8 | 9 | gen-text-logs-%: $(RESULTS_DIR) 10 | LOG_LEVEL=$* LOG_FORMAT=text $(MAKE) run > $(RESULTS_DIR)/$*-log.txt 11 | 12 | gen-json-logs-%: $(RESULTS_DIR) 13 | LOG_LEVEL=$* LOG_FORMAT=json $(MAKE) run > $(RESULTS_DIR)/$*-log.json 14 | 15 | gen-text-logs: gen-text-logs-info gen-text-logs-debug gen-text-logs-trace gen-text-logs-verbose 16 | gen-json-logs: gen-json-logs-info gen-json-logs-debug gen-json-logs-trace gen-json-logs-verbose 17 | 18 | .PHONY: gen 19 | gen: gen-text-logs gen-json-logs-verbose 20 | 21 | .PHONY: clean 22 | clean: 23 | 24 | .PHONY: clobber 25 | clobber: clean 26 | rm -rf $(RESULTS_DIR) 27 | -------------------------------------------------------------------------------- /examples/with-sqlc/go.mod: -------------------------------------------------------------------------------- 1 | module tutorial.sqlc.dev/app 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/akm/sql-slog v0.2.0 // indirect 7 | github.com/dustin/go-humanize v1.0.1 // indirect 8 | github.com/google/uuid v1.6.0 // indirect 9 | github.com/mattn/go-isatty v0.0.20 // indirect 10 | github.com/ncruces/go-strftime v0.1.9 // indirect 11 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 12 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect 13 | golang.org/x/sys v0.30.0 // indirect 14 | modernc.org/libc v1.61.13 // indirect 15 | modernc.org/mathutil v1.7.1 // indirect 16 | modernc.org/memory v1.8.2 // indirect 17 | modernc.org/sqlite v1.36.1 // indirect 18 | ) 19 | 20 | replace github.com/akm/sql-slog => ../.. 21 | -------------------------------------------------------------------------------- /mock_driver_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog_test 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | ) 7 | 8 | type MockDriver struct { 9 | OpenResult driver.Conn 10 | OpenError error 11 | } 12 | 13 | func (m *MockDriver) Open(string) (driver.Conn, error) { 14 | return m.OpenResult, m.OpenError 15 | } 16 | 17 | var mockDriver driver.Driver = &MockDriver{} 18 | 19 | type MockConn struct{} 20 | 21 | func (m *MockConn) Begin() (driver.Tx, error) { 22 | return nil, nil // nolint: nilnil 23 | } 24 | 25 | func (m *MockConn) Close() error { 26 | return nil 27 | } 28 | 29 | func (m *MockConn) Prepare(string) (driver.Stmt, error) { 30 | return nil, nil // nolint: nilnil 31 | } 32 | 33 | var _ driver.Conn = (*MockConn)(nil) 34 | 35 | func init() { // nolint: gochecknoinits 36 | sql.Register("mock", mockDriver) 37 | } 38 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import "testing" 4 | 5 | func TestEventString(t *testing.T) { 6 | t.Parallel() 7 | tests := []struct { 8 | name string 9 | e Event 10 | want string 11 | }{ 12 | { 13 | name: "EventStart", 14 | e: EventStart, 15 | want: "Start", 16 | }, 17 | { 18 | name: "EventError", 19 | e: EventError, 20 | want: "Error", 21 | }, 22 | { 23 | name: "EventComplete", 24 | e: EventComplete, 25 | want: "Complete", 26 | }, 27 | { 28 | name: "Unknown", 29 | e: Event(0), 30 | want: "Unknown", 31 | }, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | t.Parallel() 36 | if got := tt.e.String(); got != tt.want { 37 | t.Errorf("Event.String() = %v, want %v", got, tt.want) 38 | } 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | # There are no dependency in /go.mod but check go.mod just in case 9 | - directory: "/" # Location of package manifests 10 | package-ecosystem: "gomod" # See documentation for possible values 11 | schedule: 12 | interval: "daily" 13 | reviewers: 14 | - akm 15 | - directory: "/tests/mysql" # Location of package manifests 16 | package-ecosystem: "gomod" # See documentation for possible values 17 | schedule: 18 | interval: "daily" 19 | reviewers: 20 | - akm 21 | -------------------------------------------------------------------------------- /dsn_connector_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "testing" 7 | ) 8 | 9 | type mockDriverForDsnConnector struct { 10 | resultConn driver.Conn 11 | resultErr error 12 | } 13 | 14 | var _ driver.Driver = (*mockDriverForDsnConnector)(nil) 15 | 16 | func (m *mockDriverForDsnConnector) Open(string) (driver.Conn, error) { 17 | return m.resultConn, m.resultErr 18 | } 19 | 20 | func TestDsnConnector(t *testing.T) { 21 | t.Parallel() 22 | var d dsnConnector 23 | dsn := "dsn" 24 | drv := &mockDriverForDsnConnector{} 25 | d = dsnConnector{dsn: dsn, driver: drv} 26 | 27 | t.Run("Connect", func(t *testing.T) { 28 | t.Parallel() 29 | _, _ = d.Connect(context.Background()) 30 | }) 31 | 32 | t.Run("Driver", func(t *testing.T) { 33 | t.Parallel() 34 | if d.Driver() != drv { 35 | t.Errorf("expected %v, got %v", drv, d.Driver()) 36 | } 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /options_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import "testing" 4 | 5 | func TestSetStepEventMsgBuilder(t *testing.T) { // nolint:paralleltest 6 | t.Run("defaultOptions", func(t *testing.T) { // nolint:paralleltest 7 | opts := newOptions("dummy") 8 | if opts.DriverOptions.ConnOptions.Begin.Complete.Msg != "Conn.Begin" { 9 | t.Errorf("unexpected default value: %s", opts.DriverOptions.ConnOptions.Begin.Complete.Msg) 10 | } 11 | }) 12 | 13 | t.Run("CustomStepEventMsgBuilder", func(t *testing.T) { // nolint:paralleltest 14 | builder, backup := StepEventMsgWithEventName, stepEventMsgBuilder 15 | SetStepEventMsgBuilder(builder) 16 | defer SetStepEventMsgBuilder(backup) 17 | opts := newOptions("dummy") 18 | if opts.DriverOptions.ConnOptions.Begin.Complete.Msg != "Conn.Begin Complete" { 19 | t.Errorf("unexpected default value: %s", opts.DriverOptions.ConnOptions.Begin.Complete.Msg) 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /tests/testhelper/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /tests/test.mk: -------------------------------------------------------------------------------- 1 | GO_TEST_OPTIONS?= 2 | 3 | .PHONY: test 4 | test: 5 | go test $(GO_TEST_OPTIONS) ./... 6 | 7 | GO_COVERAGE_DIR=coverage/unit 8 | $(GO_COVERAGE_DIR): 9 | mkdir -p $(GO_COVERAGE_DIR) 10 | GO_COVERAGE_HTML?=coverage.html 11 | GO_COVERAGE_PROFILE?=coverage.txt 12 | $(GO_COVERAGE_PROFILE): 13 | $(MAKE) test-with-coverage 14 | 15 | # See https://app.codecov.io/github/akm/go-requestid/new 16 | .PHONY: test-with-coverage 17 | test-with-coverage: $(GO_COVERAGE_DIR) 18 | go test -cover -coverpkg=github.com/akm/sql-slog ./... -args -test.gocoverdir="$(GO_COVERAGE_DIR)" 19 | 20 | .PHONY: test-coverage 21 | test-coverage: $(GO_COVERAGE_PROFILE) 22 | go tool covdata percent -i=$(GO_COVERAGE_DIR) -o $(GO_COVERAGE_PROFILE) 23 | go tool cover -html=$(GO_COVERAGE_PROFILE) -o $(GO_COVERAGE_HTML) 24 | @command -v open && open $(GO_COVERAGE_HTML) || echo "open $(GO_COVERAGE_HTML)" 25 | 26 | .PHONY: clean 27 | clean: 28 | rm -rf coverage 29 | rm -f $(GO_COVERAGE_HTML) $(GO_COVERAGE_PROFILE) 30 | 31 | .PHONY: clobber 32 | clobber: clean 33 | -------------------------------------------------------------------------------- /open_example_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog_test 2 | 3 | import ( 4 | "context" 5 | 6 | sqlslog "github.com/akm/sql-slog" 7 | // _ "github.com/mattn/go-sqlite3" 8 | ) 9 | 10 | func ExampleOpen() { 11 | dsn := "file::memory:?cache=shared" 12 | ctx := context.TODO() 13 | db, logger, err := sqlslog.Open(ctx, "sqlite3", dsn) 14 | if err != nil { 15 | // Handle error 16 | } 17 | defer db.Close() 18 | // Use db as a regular *sql.DB 19 | logger.InfoContext(ctx, "Hello, World!") 20 | } 21 | 22 | func ExampleOpen_withLevel() { 23 | dsn := "file::memory:?cache=shared" 24 | ctx := context.TODO() 25 | db, _, _ := sqlslog.Open(ctx, "sqlite3", dsn, 26 | sqlslog.LogLevel(sqlslog.LevelTrace), 27 | ) 28 | defer db.Close() 29 | } 30 | 31 | func ExampleOpen_withStmtQueryContext() { 32 | dsn := "file::memory:?cache=shared" 33 | ctx := context.TODO() 34 | db, _, _ := sqlslog.Open(ctx, "sqlite3", dsn, 35 | sqlslog.LogLevel(sqlslog.LevelTrace), 36 | sqlslog.StmtQueryContext(func(o *sqlslog.StepOptions) { 37 | o.SetLevel(sqlslog.LevelDebug) 38 | }), 39 | ) 40 | defer db.Close() 41 | } 42 | -------------------------------------------------------------------------------- /tests/postgres/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 4 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 8 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /tests/sqlite3/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 4 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 8 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /tx.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "database/sql/driver" 5 | ) 6 | 7 | type txOptions struct { 8 | Commit StepOptions 9 | Rollback StepOptions 10 | } 11 | 12 | func defaultTxOptions(msgb StepEventMsgBuilder) *txOptions { 13 | return &txOptions{ 14 | Commit: *defaultStepOptions(msgb, StepTxCommit, LevelInfo), 15 | Rollback: *defaultStepOptions(msgb, StepTxRollback, LevelInfo), 16 | } 17 | } 18 | 19 | func wrapTx(original driver.Tx, logger *stepLogger, options *txOptions) *txWrapper { 20 | return &txWrapper{original: original, logger: logger, options: options} 21 | } 22 | 23 | type txWrapper struct { 24 | original driver.Tx 25 | logger *stepLogger 26 | options *txOptions 27 | } 28 | 29 | var _ driver.Tx = (*txWrapper)(nil) 30 | 31 | // Commit implements driver.Tx. 32 | func (t *txWrapper) Commit() error { 33 | return ignoreAttr(t.logger.StepWithoutContext(&t.options.Commit, withNilAttr(t.original.Commit))) 34 | } 35 | 36 | // Rollback implements driver.Tx. 37 | func (t *txWrapper) Rollback() error { 38 | return ignoreAttr(t.logger.StepWithoutContext(&t.options.Rollback, withNilAttr(t.original.Rollback))) 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Takeshi Akima 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /options_example_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog_test 2 | 3 | import ( 4 | "context" 5 | 6 | sqlslog "github.com/akm/sql-slog" 7 | // _ "github.com/mattn/go-sqlite3" 8 | ) 9 | 10 | func ExampleHandlerFunc() { 11 | dsn := "file::memory:?cache=shared" 12 | ctx := context.TODO() 13 | db, logger, _ := sqlslog.Open(ctx, "sqlite3", dsn, 14 | sqlslog.HandlerFunc(sqlslog.NewJSONHandler), 15 | ) 16 | defer db.Close() 17 | logger.InfoContext(ctx, "Hello, World!") 18 | } 19 | 20 | func ExampleSetStepEventMsgBuilder() { 21 | sqlslog.SetStepEventMsgBuilder(func(step sqlslog.Step, event sqlslog.Event) string { 22 | return "PRFIX:" + step.String() + "/" + event.String() + ":SUFFIX" 23 | }) 24 | defer sqlslog.SetStepEventMsgBuilder(sqlslog.StepEventMsgWithoutEventName) 25 | 26 | dsn := "dummy-dsn" 27 | ctx := context.TODO() 28 | db, logger, _ := sqlslog.Open(ctx, "mock", dsn, 29 | sqlslog.LogReplaceAttr(removeTimeAndDuration), // for testing 30 | ) 31 | defer db.Close() 32 | logger.InfoContext(ctx, "Hello, World!") 33 | 34 | // Output: 35 | // level=INFO msg=PRFIX:Open/Complete:SUFFIX driver=mock dsn=dummy-dsn 36 | // level=INFO msg="Hello, World!" 37 | } 38 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/results/info-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:23:51.530+09:00 level=INFO msg=Open driver=sqlite3 dsn="file::memory:?cache=shared" duration=121792 2 | time=2025-08-06T23:23:51.531+09:00 level=INFO msg=Driver.Open dsn="file::memory:?cache=shared" duration=967542 conn_id=Zs8L4IcPUoMyNZhn 3 | time=2025-08-06T23:23:51.531+09:00 level=INFO msg=Connector.Connect duration=1000917 4 | time=2025-08-06T23:23:51.531+09:00 level=INFO msg=Conn.ExecContext conn_id=Zs8L4IcPUoMyNZhn query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] duration=105041 5 | time=2025-08-06T23:23:51.531+09:00 level=INFO msg=Conn.BeginTx conn_id=Zs8L4IcPUoMyNZhn duration=4750 tx_id=oAevp73_l_CDxden 6 | time=2025-08-06T23:23:51.531+09:00 level=INFO msg=Conn.ExecContext conn_id=Zs8L4IcPUoMyNZhn query="INSERT INTO test1 (name) VALUES (?)" args="[\"Alice\"]" duration=11667 7 | time=2025-08-06T23:23:51.531+09:00 level=INFO msg=Tx.Commit conn_id=Zs8L4IcPUoMyNZhn tx_id=oAevp73_l_CDxden duration=5833 8 | time=2025-08-06T23:23:51.532+09:00 level=INFO msg=Record id=1 name=Alice 9 | time=2025-08-06T23:23:51.532+09:00 level=INFO msg=Conn.Close conn_id=Zs8L4IcPUoMyNZhn duration=19084 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | go: ["1.22", "1.23", "1.24"] 14 | name: Test on Go ${{ matrix.go }} 15 | steps: 16 | # https://github.com/actions/checkout 17 | - uses: actions/checkout@v4 18 | with: 19 | submodules: recursive 20 | 21 | # https://github.com/actions/setup-go 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ matrix.go }} 25 | 26 | - name: Check golang version/env 27 | run: | 28 | set -x 29 | go version 30 | go env 31 | 32 | - name: build 33 | run: make build 34 | 35 | - name: lint 36 | run: make lint 37 | 38 | - name: test 39 | run: make test-with-coverage 40 | 41 | - name: test-coverage-profile 42 | run: make test-coverage-profile 43 | 44 | - name: Upload coverage reports to Codecov 45 | uses: codecov/codecov-action@v5 46 | with: 47 | token: ${{ secrets.CODECOV_TOKEN }} 48 | slug: akm/sql-slog 49 | -------------------------------------------------------------------------------- /examples/with-go-requestid/go.sum: -------------------------------------------------------------------------------- 1 | github.com/akm/go-requestid v0.3.1 h1:RzKMxgQTtDvO1hUmzIJ8Xb0rFqXIQeC+rE0iF8XPEUs= 2 | github.com/akm/go-requestid v0.3.1/go.mod h1:tbraJRixVrZrkiAVyX1QfMmhMJXfBsXu5qmM6k01V/I= 3 | github.com/akm/slogctx v0.5.1 h1:hCqaAlY/2nQZpQODHErEvH10qHCIsnHeffZ1warWME4= 4 | github.com/akm/slogctx v0.5.1/go.mod h1:RnYZK+msf7mE6G0b5YsF9Z3vGhrusy1WyDsFMGZnJN0= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 8 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 12 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /duration.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | type DurationType int 4 | 5 | const ( 6 | DurationNanoSeconds DurationType = iota // Duration in nanoseconds. Durations in log are expressed by slog.Int64 7 | DurationMicroSeconds // Duration in microseconds. Durations in log are expressed by slog.Int64 8 | DurationMilliSeconds // Duration in milliseconds. Durations in log are expressed by slog.Int64 9 | DurationGoDuration // Values in log are expressed with slog.Duration 10 | DurationString // Values in log are expressed with slog.String and time.Duration.String 11 | ) 12 | 13 | // Duration is an option to specify duration value in log. 14 | // The default is DurationNanoSeconds. 15 | func Duration(v DurationType) Option { 16 | return func(o *options) { 17 | o.stepLoggerOptions.durationType = v 18 | } 19 | } 20 | 21 | // DurationKey is an option to specify the key for duration value in log. 22 | // The default is specified by DurationKeyDefault. 23 | func DurationKey(key string) Option { 24 | return func(o *options) { 25 | o.stepLoggerOptions.durationKey = key 26 | } 27 | } 28 | 29 | // DurationKeyDefault is the default key for duration value in log. 30 | const DurationKeyDefault = "duration" 31 | -------------------------------------------------------------------------------- /tests/mysql/go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= 6 | github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 10 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /open.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "log/slog" 7 | ) 8 | 9 | /* 10 | Open opens a database specified by its driver name and a driver-specific data source name, 11 | and returns a new database handle with logging capabilities. 12 | 13 | ctx is the context for the open operation. 14 | driverName is the name of the database driver, same as the driverName in [sql.Open]. 15 | dsn is the data source name, same as the dataSourceName in [sql.Open]. 16 | opts are the options for logging behavior. See [Option] for details. 17 | 18 | The returned DB can be used the same way as *sql.DB from [sql.Open]. 19 | 20 | See the following example for usage: 21 | 22 | [Logger]: sets the slog.Logger to be used. If not set, the default is slog.Default(). 23 | 24 | [StepOptions]: sets the options for logging behavior. 25 | 26 | [SetStepEventMsgBuilder]: sets the function to format the step name. 27 | 28 | [sql.Open]: https://pkg.go.dev/database/sql#Open 29 | */ 30 | func Open(ctx context.Context, driverName, dsn string, opts ...Option) (*sql.DB, *slog.Logger, error) { // nolint:funlen 31 | factory := New(driverName, dsn, opts...) 32 | db, err := factory.Open(ctx) 33 | if err != nil { 34 | return nil, nil, err 35 | } 36 | return db, factory.Logger(), nil 37 | } 38 | -------------------------------------------------------------------------------- /step.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | type Step string 4 | 5 | func (s Step) String() string { 6 | return string(s) 7 | } 8 | 9 | const ( 10 | StepConnBegin Step = "Conn.Begin" 11 | StepConnBeginTx Step = "Conn.BeginTx" 12 | StepConnClose Step = "Conn.Close" 13 | StepConnPrepare Step = "Conn.Prepare" 14 | StepConnPrepareContext Step = "Conn.PrepareContext" 15 | StepConnResetSession Step = "Conn.ResetSession" 16 | StepConnPing Step = "Conn.Ping" 17 | StepConnExecContext Step = "Conn.ExecContext" 18 | StepConnQueryContext Step = "Conn.QueryContext" 19 | 20 | StepConnectorConnect Step = "Connector.Connect" 21 | 22 | StepDriverOpen Step = "Driver.Open" 23 | StepDriverOpenConnector Step = "Driver.OpenConnector" 24 | 25 | StepSqlslogOpen Step = "Open" 26 | 27 | StepRowsClose Step = "Rows.Close" 28 | StepRowsNext Step = "Rows.Next" 29 | StepRowsNextResultSet Step = "Rows.NextResultSet" 30 | 31 | StepStmtClose Step = "Stmt.Close" 32 | StepStmtExec Step = "Stmt.Exec" 33 | StepStmtQuery Step = "Stmt.Query" 34 | StepStmtExecContext Step = "Stmt.ExecContext" 35 | StepStmtQueryContext Step = "Stmt.QueryContext" 36 | 37 | StepTxCommit Step = "Tx.Commit" 38 | StepTxRollback Step = "Tx.Rollback" 39 | ) 40 | -------------------------------------------------------------------------------- /examples/with-go-requestid/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "os" 8 | 9 | "log/slog" 10 | 11 | requestid "github.com/akm/go-requestid" 12 | sqlslog "github.com/akm/sql-slog" 13 | _ "github.com/mattn/go-sqlite3" 14 | ) 15 | 16 | func main() { 17 | sqlslogMiddleware := sqlslog.New("sqlite3", ":memory:", 18 | sqlslog.LogLevel(sqlslog.LevelTrace), 19 | sqlslog.HandlerFunc(func(w io.Writer, opts *slog.HandlerOptions) slog.Handler { 20 | return requestid.WrapSlogHandler(sqlslog.NewTextHandler(w, opts)) 21 | }), 22 | ) 23 | 24 | ctx := context.Background() 25 | 26 | var err error 27 | db, err = sqlslogMiddleware.Open(ctx) 28 | if err != nil { 29 | slog.ErrorContext(ctx, "Failed to open database", "error", err) 30 | return 31 | } 32 | defer db.Close() 33 | 34 | slog.SetDefault(sqlslogMiddleware.Logger()) 35 | 36 | createTable(ctx) 37 | 38 | mux := http.NewServeMux() 39 | mux.HandleFunc("GET /todos", getTodos) 40 | mux.HandleFunc("POST /todos", createTodo) 41 | mux.HandleFunc("GET /todos/{id}", getTodoByID) 42 | mux.HandleFunc("PUT /todos/{id}", updateTodoByID) 43 | mux.HandleFunc("DELETE /todos/{id}", deleteTodoByID) 44 | 45 | slog.InfoContext(ctx, "Starting server on :8080", slog.Int("pid", os.Getpid())) 46 | slog.ErrorContext(ctx, http.ListenAndServe(":8080", requestid.Wrap(mux)).Error()) 47 | } 48 | -------------------------------------------------------------------------------- /id_gen_example_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog_test 2 | 3 | import ( 4 | "context" 5 | cryptorand "crypto/rand" 6 | mathrandv2 "math/rand/v2" 7 | 8 | sqlslog "github.com/akm/sql-slog" 9 | // _ "github.com/mattn/go-sqlite3" 10 | ) 11 | 12 | func ExampleIDGenerator() { 13 | idGen := sqlslog.RandIntIDGenerator( 14 | mathrandv2.Int, 15 | []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"), 16 | 8, 17 | ) 18 | 19 | db, _, _ := sqlslog.Open(context.TODO(), "sqlite3", "file::memory:?cache=shared", 20 | sqlslog.HandlerFunc(sqlslog.NewJSONHandler), 21 | sqlslog.IDGenerator(idGen), 22 | ) 23 | defer db.Close() 24 | } 25 | 26 | func ExampleRandIntIDGenerator() { 27 | idGen := sqlslog.RandIntIDGenerator( 28 | mathrandv2.Int, 29 | []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"), 30 | 8, 31 | ) 32 | 33 | db, _, _ := sqlslog.Open(context.TODO(), "sqlite3", "file::memory:?cache=shared", 34 | sqlslog.HandlerFunc(sqlslog.NewJSONHandler), 35 | sqlslog.IDGenerator(idGen), 36 | ) 37 | defer db.Close() 38 | } 39 | 40 | func ExampleIDGenErrorSuppressor() { 41 | idGen := sqlslog.IDGenErrorSuppressor( 42 | sqlslog.RandReadIDGenerator( 43 | cryptorand.Read, 44 | []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"), 45 | 8, 46 | ), 47 | func(error) string { return "recovered" }, 48 | ) 49 | 50 | db, _, _ := sqlslog.Open(context.TODO(), "sqlite3", "file::memory:?cache=shared", 51 | sqlslog.HandlerFunc(sqlslog.NewJSONHandler), 52 | sqlslog.IDGenerator(idGen), 53 | ) 54 | defer db.Close() 55 | } 56 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/results/debug-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:23:51.824+09:00 level=INFO msg=Open driver=sqlite3 dsn="file::memory:?cache=shared" duration=45709 2 | time=2025-08-06T23:23:51.825+09:00 level=INFO msg=Driver.Open dsn="file::memory:?cache=shared" duration=546334 conn_id=BmDho0jLHtC3vspy 3 | time=2025-08-06T23:23:51.825+09:00 level=INFO msg=Connector.Connect duration=557875 4 | time=2025-08-06T23:23:51.825+09:00 level=INFO msg=Conn.ExecContext conn_id=BmDho0jLHtC3vspy query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] duration=58417 5 | time=2025-08-06T23:23:51.825+09:00 level=INFO msg=Conn.BeginTx conn_id=BmDho0jLHtC3vspy duration=2209 tx_id=UefG6gyYqvfm45nQ 6 | time=2025-08-06T23:23:51.825+09:00 level=INFO msg=Conn.ExecContext conn_id=BmDho0jLHtC3vspy query="INSERT INTO test1 (name) VALUES (?)" args="[\"Alice\"]" duration=7375 7 | time=2025-08-06T23:23:51.825+09:00 level=INFO msg=Tx.Commit conn_id=BmDho0jLHtC3vspy tx_id=UefG6gyYqvfm45nQ duration=3125 8 | time=2025-08-06T23:23:51.825+09:00 level=DEBUG msg=Conn.QueryContext conn_id=BmDho0jLHtC3vspy query="SELECT * FROM test1" args=[] duration=3958 9 | time=2025-08-06T23:23:51.825+09:00 level=DEBUG msg=Rows.Next conn_id=BmDho0jLHtC3vspy duration=2292 eof=false 10 | time=2025-08-06T23:23:51.825+09:00 level=INFO msg=Record id=1 name=Alice 11 | time=2025-08-06T23:23:51.825+09:00 level=DEBUG msg=Rows.Next conn_id=BmDho0jLHtC3vspy duration=833 eof=true 12 | time=2025-08-06T23:23:51.825+09:00 level=DEBUG msg=Rows.Close conn_id=BmDho0jLHtC3vspy duration=375 13 | time=2025-08-06T23:23:51.825+09:00 level=INFO msg=Conn.Close conn_id=BmDho0jLHtC3vspy duration=9208 14 | -------------------------------------------------------------------------------- /examples/with-sqlc/tutorial.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | _ "embed" 7 | "log" 8 | "os" 9 | "reflect" 10 | 11 | sqlslog "github.com/akm/sql-slog" 12 | _ "modernc.org/sqlite" 13 | 14 | "tutorial.sqlc.dev/app/tutorial" 15 | ) 16 | 17 | //go:embed schema.sql 18 | var ddl string 19 | 20 | func run() error { 21 | ctx := context.Background() 22 | 23 | var db *sql.DB 24 | var err error 25 | if os.Getenv("SKIP_SQLSLOG") != "" { 26 | db, err = sql.Open("sqlite", ":memory:") 27 | } else { 28 | db, _, err = sqlslog.Open(ctx, "sqlite", ":memory:") 29 | } 30 | if err != nil { 31 | return err 32 | } 33 | 34 | // create tables 35 | if _, err := db.ExecContext(ctx, ddl); err != nil { 36 | return err 37 | } 38 | 39 | queries := tutorial.New(db) 40 | 41 | // list all authors 42 | authors, err := queries.ListAuthors(ctx) 43 | if err != nil { 44 | return err 45 | } 46 | log.Println(authors) 47 | 48 | // create an author 49 | insertedAuthor, err := queries.CreateAuthor(ctx, tutorial.CreateAuthorParams{ 50 | Name: "Brian Kernighan", 51 | Bio: sql.NullString{String: "Co-author of The C Programming Language and The Go Programming Language", Valid: true}, 52 | }) 53 | if err != nil { 54 | return err 55 | } 56 | log.Println(insertedAuthor) 57 | 58 | // get the author we just inserted 59 | fetchedAuthor, err := queries.GetAuthor(ctx, insertedAuthor.ID) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | // prints true 65 | log.Println(reflect.DeepEqual(insertedAuthor, fetchedAuthor)) 66 | return nil 67 | } 68 | 69 | func main() { 70 | if err := run(); err != nil { 71 | log.Fatal(err) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /open_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "errors" 7 | "log/slog" 8 | "testing" 9 | ) 10 | 11 | func TestOpen(t *testing.T) { 12 | t.Parallel() 13 | ctx := context.TODO() 14 | 15 | db, _, err := Open(ctx, "invalid-driver", "") 16 | if err == nil { 17 | t.Fatal("Expected error") 18 | } 19 | if err.Error() != "sql: unknown driver \"invalid-driver\" (forgotten import?)" { 20 | t.Fatalf("Unexpected error: %v", err) 21 | } 22 | if db != nil { 23 | t.Fatal("Expected nil db") 24 | } 25 | } 26 | 27 | type errorDriverContext struct { 28 | error error 29 | } 30 | 31 | var ( 32 | _ driver.Driver = (*errorDriverContext)(nil) 33 | _ driver.DriverContext = (*errorDriverContext)(nil) 34 | ) 35 | 36 | func newErrorDriverContext(err error) *errorDriverContext { 37 | return &errorDriverContext{error: err} 38 | } 39 | 40 | // OpenConnector implements driver.DriverContext. 41 | func (e *errorDriverContext) OpenConnector(string) (driver.Connector, error) { 42 | return nil, e.error 43 | } 44 | 45 | // Open implements driver.Driver. 46 | func (e *errorDriverContext) Open(string) (driver.Conn, error) { 47 | return nil, e.error 48 | } 49 | 50 | func TestOpenWithDriver(t *testing.T) { 51 | t.Parallel() 52 | t.Run("unknown error", func(t *testing.T) { 53 | t.Parallel() 54 | t.Run("DriverContext", func(t *testing.T) { 55 | drv := newErrorDriverContext(errors.New("unknown error")) 56 | stepLogger := newStepLogger(slog.New(slog.NewJSONHandler(nil, nil)), defaultStepLoggerOptions()) 57 | if _, err := openWithWrappedDriver(drv, "invalid-dsn", stepLogger, nil); err == nil { 58 | t.Fatal("Expected error") 59 | } 60 | }) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /duration_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestDurationString(t *testing.T) { 9 | t.Parallel() 10 | testcases := []struct { 11 | gen func() time.Duration 12 | expected string 13 | }{ 14 | { 15 | gen: func() time.Duration { return time.Duration(0) }, 16 | expected: "0s", 17 | }, 18 | { 19 | gen: func() time.Duration { return time.Duration(1) }, 20 | expected: "1ns", 21 | }, 22 | { 23 | gen: func() time.Duration { return time.Duration(1e3) }, 24 | expected: "1µs", 25 | }, 26 | { 27 | gen: func() time.Duration { return time.Duration(1e6) }, 28 | expected: "1ms", 29 | }, 30 | { 31 | gen: func() time.Duration { return time.Duration(1e9) }, 32 | expected: "1s", 33 | }, 34 | { 35 | gen: func() time.Duration { return time.Duration(1e9 + 1) }, 36 | expected: "1.000000001s", 37 | }, 38 | { 39 | gen: func() time.Duration { return time.Duration(1e9 + 1e3) }, 40 | expected: "1.000001s", 41 | }, 42 | { 43 | gen: func() time.Duration { return time.Duration(1e9 * 60) }, 44 | expected: "1m0s", 45 | }, 46 | { 47 | gen: func() time.Duration { return time.Duration(1e9*60 + 1) }, 48 | expected: "1m0.000000001s", 49 | }, 50 | { 51 | gen: func() time.Duration { return time.Duration(1e9 * 60 * 60) }, 52 | expected: "1h0m0s", 53 | }, 54 | } 55 | 56 | for _, tc := range testcases { 57 | t.Run(tc.expected, func(t *testing.T) { 58 | t.Parallel() 59 | d := tc.gen() 60 | actual := d.String() 61 | if actual != tc.expected { 62 | t.Errorf("expected: %s, but got %s", tc.expected, actual) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/logs-postgres/results/info-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:23:53.060+09:00 level=INFO msg=Open driver=postgres dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=76416 2 | time=2025-08-06T23:23:53.061+09:00 level=ERROR msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=605917 error="read tcp 127.0.0.1:61632->127.0.0.1:5432: read: connection reset by peer" 3 | time=2025-08-06T23:23:53.061+09:00 level=ERROR msg=Connector.Connect duration=635333 error="read tcp 127.0.0.1:61632->127.0.0.1:5432: read: connection reset by peer" 4 | time=2025-08-06T23:23:55.081+09:00 level=INFO msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=18800167 conn_id=DND7Y_XZmy77URni 5 | time=2025-08-06T23:23:55.081+09:00 level=INFO msg=Connector.Connect duration=19007083 6 | time=2025-08-06T23:23:55.085+09:00 level=INFO msg=Conn.ExecContext conn_id=DND7Y_XZmy77URni query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] duration=3010292 7 | time=2025-08-06T23:23:55.086+09:00 level=INFO msg=Conn.BeginTx conn_id=DND7Y_XZmy77URni duration=297875 tx_id=W2A2VLIfqrhlDGJG 8 | time=2025-08-06T23:23:55.087+09:00 level=INFO msg=Conn.ExecContext conn_id=DND7Y_XZmy77URni query="INSERT INTO test1 (id, name) VALUES ($1,$2);" args="[1,\"Alice\"]" duration=1336083 9 | time=2025-08-06T23:23:55.088+09:00 level=INFO msg=Tx.Commit conn_id=DND7Y_XZmy77URni tx_id=W2A2VLIfqrhlDGJG duration=682875 10 | time=2025-08-06T23:23:55.088+09:00 level=INFO msg=Record id=1 name=Alice 11 | time=2025-08-06T23:23:55.088+09:00 level=INFO msg=Conn.Close conn_id=DND7Y_XZmy77URni duration=17250 12 | -------------------------------------------------------------------------------- /examples/with-sqlc/go.sum: -------------------------------------------------------------------------------- 1 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 2 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 3 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 4 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 6 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 7 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 8 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 9 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 10 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 11 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= 12 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= 13 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 14 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 15 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 16 | modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= 17 | modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= 18 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 19 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 20 | modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= 21 | modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= 22 | modernc.org/sqlite v1.36.1 h1:bDa8BJUH4lg6EGkLbahKe/8QqoF8p9gArSc6fTqYhyQ= 23 | modernc.org/sqlite v1.36.1/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU= 24 | -------------------------------------------------------------------------------- /examples/logs-postgres/results/debug-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:23:56.128+09:00 level=INFO msg=Open driver=postgres dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=68750 2 | time=2025-08-06T23:23:56.129+09:00 level=ERROR msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=788500 error="read tcp 127.0.0.1:61635->127.0.0.1:5432: read: connection reset by peer" 3 | time=2025-08-06T23:23:56.129+09:00 level=ERROR msg=Connector.Connect duration=819625 error="read tcp 127.0.0.1:61635->127.0.0.1:5432: read: connection reset by peer" 4 | time=2025-08-06T23:23:58.139+09:00 level=INFO msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=9617292 conn_id=R7iQY28Osd_CJyuz 5 | time=2025-08-06T23:23:58.139+09:00 level=INFO msg=Connector.Connect duration=9788167 6 | time=2025-08-06T23:23:58.144+09:00 level=INFO msg=Conn.ExecContext conn_id=R7iQY28Osd_CJyuz query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] duration=3789375 7 | time=2025-08-06T23:23:58.144+09:00 level=INFO msg=Conn.BeginTx conn_id=R7iQY28Osd_CJyuz duration=288792 tx_id=ACeDDgjkccTVtwMr 8 | time=2025-08-06T23:23:58.145+09:00 level=INFO msg=Conn.ExecContext conn_id=R7iQY28Osd_CJyuz query="INSERT INTO test1 (id, name) VALUES ($1,$2);" args="[1,\"Alice\"]" duration=1136667 9 | time=2025-08-06T23:23:58.146+09:00 level=INFO msg=Tx.Commit conn_id=R7iQY28Osd_CJyuz tx_id=ACeDDgjkccTVtwMr duration=774750 10 | time=2025-08-06T23:23:58.147+09:00 level=DEBUG msg=Conn.QueryContext conn_id=R7iQY28Osd_CJyuz query="SELECT * FROM test1" args=[] duration=392292 11 | time=2025-08-06T23:23:58.147+09:00 level=DEBUG msg=Rows.Next conn_id=R7iQY28Osd_CJyuz duration=1584 eof=false 12 | time=2025-08-06T23:23:58.147+09:00 level=INFO msg=Record id=1 name=Alice 13 | time=2025-08-06T23:23:58.147+09:00 level=DEBUG msg=Rows.Next conn_id=R7iQY28Osd_CJyuz duration=583 eof=true 14 | time=2025-08-06T23:23:58.147+09:00 level=DEBUG msg=Rows.Close conn_id=R7iQY28Osd_CJyuz duration=42 15 | time=2025-08-06T23:23:58.147+09:00 level=INFO msg=Conn.Close conn_id=R7iQY28Osd_CJyuz duration=17459 16 | -------------------------------------------------------------------------------- /step_options_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import "testing" 4 | 5 | func TestStepOptionsSetLevel(t *testing.T) { 6 | t.Parallel() 7 | 8 | newOpt := func(start, err, comp Level) *StepOptions { 9 | return &StepOptions{ 10 | Start: EventOptions{Level: start}, 11 | Error: EventOptions{Level: err}, 12 | Complete: EventOptions{Level: comp}, 13 | } 14 | } 15 | 16 | tests := []struct { 17 | name string 18 | o *StepOptions 19 | lv Level 20 | want *StepOptions 21 | }{ 22 | { 23 | name: "LevelTrace", 24 | o: newOpt(LevelDebug, LevelError, LevelInfo), 25 | lv: LevelTrace, 26 | want: newOpt(LevelVerbose, LevelError, LevelTrace), 27 | }, 28 | { 29 | name: "LevelDebug", 30 | o: newOpt(LevelInfo, LevelError, LevelInfo), 31 | lv: LevelDebug, 32 | want: newOpt(LevelTrace, LevelError, LevelDebug), 33 | }, 34 | { 35 | name: "LevelInfo", 36 | o: newOpt(LevelTrace, LevelError, LevelDebug), 37 | lv: LevelInfo, 38 | want: newOpt(LevelDebug, LevelError, LevelInfo), 39 | }, 40 | { 41 | name: "LevelWarn", 42 | o: newOpt(LevelTrace, LevelError, LevelDebug), 43 | lv: LevelWarn, 44 | want: newOpt(LevelInfo, LevelError, LevelWarn), 45 | }, 46 | { 47 | name: "LevelError", 48 | o: newOpt(LevelTrace, LevelError, LevelDebug), 49 | lv: LevelError, 50 | want: newOpt(LevelWarn, LevelError, LevelError), 51 | }, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | t.Parallel() 56 | tt.o.SetLevel(tt.lv) 57 | if !tt.o.compare(tt.want) { 58 | t.Errorf("StepOptions.SetLevel() = %v, want %v", tt.o, tt.want) 59 | } 60 | if tt.o.Complete.Level != tt.lv { 61 | t.Errorf("StepOptions.SetLevel() = %v, want %v", tt.o.Complete.Level, tt.lv) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | func TestDefaultStepOptions(t *testing.T) { 68 | t.Parallel() 69 | t.Run("LevelError", func(t *testing.T) { 70 | t.Parallel() 71 | o := defaultStepOptions(StepEventMsgWithoutEventName, Step("test"), LevelError) 72 | if o.Start.Level != LevelDebug { 73 | t.Errorf("Expected %v, but got %v", LevelDebug, o.Start.Level) 74 | } 75 | if o.Complete.Level != LevelError { 76 | t.Errorf("Expected %v, but got %v", LevelError, o.Complete.Level) 77 | } 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "slices" 9 | 10 | sqlslog "github.com/akm/sql-slog" 11 | _ "github.com/mattn/go-sqlite3" 12 | ) 13 | 14 | func main() { 15 | logLevel := sqlslog.ParseLevelWithDefault(os.Args[1], sqlslog.LevelInfo) 16 | 17 | var handlerFunc func(io.Writer, *slog.HandlerOptions) slog.Handler 18 | if slices.Contains(os.Args, "json") { 19 | handlerFunc = sqlslog.NewJSONHandler 20 | } else { 21 | handlerFunc = sqlslog.NewTextHandler 22 | } 23 | 24 | ctx := context.Background() 25 | dsn := "file::memory:?cache=shared" 26 | 27 | // Open a database 28 | db, logger, err := sqlslog.Open(ctx, "sqlite3", dsn, 29 | sqlslog.LogLevel(logLevel), 30 | sqlslog.HandlerFunc(handlerFunc), 31 | sqlslog.ConnQueryContext(func(o *sqlslog.StepOptions) { 32 | o.SetLevel(sqlslog.LevelDebug) 33 | }), 34 | ) 35 | if err != nil { 36 | logger.Error("Failed to open database", "error", err) 37 | os.Exit(1) 38 | } 39 | defer db.Close() 40 | 41 | // Create a table 42 | query := "CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" 43 | if _, err := db.ExecContext(ctx, query); err != nil { 44 | logger.Error("Failed to create a table", "error", err) 45 | os.Exit(1) 46 | } 47 | 48 | // Insert a record in a transaction 49 | tx, err := db.BeginTx(ctx, nil) 50 | if err != nil { 51 | logger.Error("Failed to begin a transaction", "error", err) 52 | os.Exit(1) 53 | } 54 | 55 | query = "INSERT INTO test1 (name) VALUES (?)" 56 | if _, err := tx.ExecContext(ctx, query, "Alice"); err != nil { 57 | logger.Error("Failed to insert a record", "error", err) 58 | if err := tx.Rollback(); err != nil { 59 | logger.Error("Failed to rollback a transaction", "error", err) 60 | } 61 | os.Exit(1) 62 | } 63 | 64 | if err := tx.Commit(); err != nil { 65 | logger.Error("Failed to commit a transaction", "error", err) 66 | os.Exit(1) 67 | } 68 | 69 | // Select records 70 | rows, err := db.QueryContext(ctx, "SELECT * FROM test1") 71 | if err != nil { 72 | logger.Error("Failed to select records", "error", err) 73 | os.Exit(1) 74 | } 75 | defer rows.Close() 76 | 77 | for rows.Next() { 78 | var id int 79 | var name string 80 | if err := rows.Scan(&id, &name); err != nil { 81 | logger.Error("Failed to scan a record", "error", err) 82 | os.Exit(1) 83 | } 84 | logger.InfoContext(ctx, "Record", "id", id, "name", name) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /stmt_args.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "database/sql/driver" 5 | "fmt" 6 | "slices" 7 | "strings" 8 | ) 9 | 10 | func cmpNamedValueByOrdinal(a, b driver.NamedValue) int { 11 | return a.Ordinal - b.Ordinal 12 | } 13 | 14 | func formatNamedValues(args []driver.NamedValue) string { 15 | if len(args) == 0 { 16 | return "[]" 17 | } 18 | if !slices.ContainsFunc(args, func(arg driver.NamedValue) bool { return arg.Name == "" }) { 19 | return formatNamedValuesWithNames(args) 20 | } 21 | if !slices.ContainsFunc(args, func(arg driver.NamedValue) bool { return arg.Name != "" }) { 22 | return formatNamedValuesWithoutNames(args) 23 | } 24 | return formatNamedValuesWithMixedNames(args) 25 | } 26 | 27 | func formatNamedValuesWithNames(args []driver.NamedValue) string { 28 | var b strings.Builder 29 | b.WriteString("{") 30 | for i, arg := range args { 31 | if i > 0 { 32 | b.WriteString(",") 33 | } 34 | fmt.Fprintf(&b, "%s:", arg.Name) 35 | b.WriteString(formatValue(arg.Value)) 36 | } 37 | b.WriteString("}") 38 | return b.String() 39 | } 40 | 41 | func formatNamedValuesWithoutNames(args []driver.NamedValue) string { 42 | if !slices.IsSortedFunc(args, cmpNamedValueByOrdinal) { 43 | slices.SortFunc(args, cmpNamedValueByOrdinal) 44 | } 45 | var b strings.Builder 46 | b.WriteString("[") 47 | for i, arg := range args { 48 | if i > 0 { 49 | b.WriteString(",") 50 | } 51 | b.WriteString(formatValue(arg.Value)) 52 | } 53 | b.WriteString("]") 54 | return b.String() 55 | } 56 | 57 | func formatNamedValuesWithMixedNames(args []driver.NamedValue) string { 58 | var b strings.Builder 59 | b.WriteString("[") 60 | for i, arg := range args { 61 | if i > 0 { 62 | b.WriteString(",") 63 | } 64 | fmt.Fprintf(&b, "[%d]%s:", arg.Ordinal, arg.Name) 65 | b.WriteString(formatValue(arg.Value)) 66 | } 67 | b.WriteString("]") 68 | return b.String() 69 | } 70 | 71 | func formatValues(values []driver.Value) string { 72 | if len(values) == 0 { 73 | return "[]" 74 | } 75 | var b strings.Builder 76 | b.WriteString("[") 77 | for i, value := range values { 78 | if i > 0 { 79 | b.WriteString(",") 80 | } 81 | b.WriteString(formatValue(value)) 82 | } 83 | b.WriteString("]") 84 | return b.String() 85 | } 86 | 87 | func formatValue(v any) string { 88 | switch v := v.(type) { 89 | case string: 90 | return fmt.Sprintf("%q", v) 91 | case []byte: 92 | return fmt.Sprintf("%q", v) 93 | default: 94 | return fmt.Sprintf("%v", v) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /level.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "strings" 8 | ) 9 | 10 | // Level is the log level for sqlslog. 11 | type Level slog.Level 12 | 13 | const ( 14 | LevelVerbose Level = Level(-12) // Lower than slog.LevelTrace. 15 | LevelTrace Level = Level(-8) // Lower than slog.LevelDebug. 16 | LevelDebug Level = Level(slog.LevelDebug) // Same as slog.LevelDebug. 17 | LevelInfo Level = Level(slog.LevelInfo) // Same as slog.LevelInfo. 18 | LevelWarn Level = Level(slog.LevelWarn) // Same as slog.LevelWarn. 19 | LevelError Level = Level(slog.LevelError) // Same as slog.LevelError. 20 | ) 21 | 22 | var _ slog.Leveler = LevelVerbose 23 | 24 | // String returns the string representation of the log level. 25 | func (l Level) String() string { 26 | str := func(base string, val Level) string { 27 | if val == 0 { 28 | return base 29 | } 30 | return fmt.Sprintf("%s%+d", base, val) 31 | } 32 | 33 | switch { 34 | case l < LevelTrace: 35 | return str("VERBOSE", l-LevelVerbose) 36 | case l < LevelDebug: 37 | return str("TRACE", l-LevelTrace) 38 | case l < LevelInfo: 39 | return str("DEBUG", l-LevelDebug) 40 | case l < LevelWarn: 41 | return str("INFO", l-LevelInfo) 42 | case l < LevelError: 43 | return str("WARN", l-LevelWarn) 44 | default: 45 | return str("ERROR", l-LevelError) 46 | } 47 | } 48 | 49 | // Level returns the slog.Level. 50 | func (l Level) Level() slog.Level { 51 | return slog.Level(l) 52 | } 53 | 54 | var stringToLevel = map[string]Level{ 55 | "VERBOSE": LevelVerbose, 56 | "TRACE": LevelTrace, 57 | "DEBUG": LevelDebug, 58 | "INFO": LevelInfo, 59 | "WARN": LevelWarn, 60 | "ERROR": LevelError, 61 | } 62 | 63 | var ErrUnknownLevel = errors.New("unknown level") 64 | 65 | func ParseLevel(s string) (Level, error) { 66 | lv, ok := stringToLevel[strings.ToUpper(s)] 67 | if !ok { 68 | return 0, fmt.Errorf("%w: %q", ErrUnknownLevel, s) 69 | } 70 | return lv, nil 71 | } 72 | 73 | func ParseLevelWithDefault(s string, def Level) Level { 74 | lv, err := ParseLevel(s) 75 | if err != nil { 76 | return def 77 | } 78 | return lv 79 | } 80 | 81 | // ReplaceLevelAttr replaces the log level as sqlslog.Level with its string representation. 82 | func ReplaceLevelAttr(_ []string, a slog.Attr) slog.Attr { 83 | // https://go.dev/src/log/slog/example_custom_levels_test.go 84 | if a.Key == slog.LevelKey { 85 | level := Level(a.Value.Any().(slog.Level)) //nolint:forcetypeassert 86 | a.Value = slog.StringValue(level.String()) 87 | } 88 | return a 89 | } 90 | -------------------------------------------------------------------------------- /step_options.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import "log/slog" 4 | 5 | type EventOptions struct { 6 | Msg string 7 | Level Level 8 | } 9 | 10 | // StepEventMsgBuilder is the function type to format the step log message. 11 | type StepEventMsgBuilder func(step Step, event Event) string 12 | 13 | // StepEventMsgWithEventName returns the formatted step log message with the event name. 14 | func StepEventMsgWithEventName(step Step, event Event) string { 15 | return step.String() + " " + event.String() 16 | } 17 | 18 | // StepEventMsgWithoutEventName returns the formatted step log message without the event name. 19 | func StepEventMsgWithoutEventName(step Step, _ Event) string { 20 | return step.String() 21 | } 22 | 23 | // StepOptions is an struct that expresses the options for the step. 24 | type StepOptions struct { 25 | Start EventOptions 26 | Error EventOptions 27 | Complete EventOptions 28 | 29 | // ErrorHandler is the function to handle the error. 30 | // When the error should not be logged as an error but as complete, it should return true. 31 | // It can also add attributes to the log. 32 | ErrorHandler func(error) (bool, []slog.Attr) 33 | } 34 | 35 | const defaultSlogLevelDiff = 4 36 | 37 | func (o *StepOptions) SetLevel(lv Level) { 38 | o.Start.Level = lv - defaultSlogLevelDiff 39 | o.Complete.Level = lv 40 | } 41 | 42 | func (o *StepOptions) compare(other *StepOptions) bool { 43 | return o.Start.Level == other.Start.Level && 44 | o.Error.Level == other.Error.Level && 45 | o.Complete.Level == other.Complete.Level 46 | } 47 | 48 | func newStepOptions(f StepEventMsgBuilder, step Step, startLevel, errorLevel, completeLevel Level) *StepOptions { 49 | return &StepOptions{ 50 | Start: EventOptions{Msg: f(step, EventStart), Level: startLevel}, 51 | Error: EventOptions{Msg: f(step, EventError), Level: errorLevel}, 52 | Complete: EventOptions{Msg: f(step, EventComplete), Level: completeLevel}, 53 | } 54 | } 55 | 56 | func defaultStepOptions(msgb StepEventMsgBuilder, step Step, completeLevel Level, errHandlers ...func(error) (bool, []slog.Attr)) *StepOptions { // nolint:unparam 57 | var startLevel Level 58 | switch completeLevel { // nolint:exhaustive 59 | case LevelError: 60 | startLevel = LevelDebug 61 | case LevelInfo: 62 | startLevel = LevelTrace 63 | case LevelDebug: 64 | startLevel = LevelVerbose 65 | default: 66 | startLevel = LevelVerbose 67 | } 68 | r := newStepOptions(msgb, step, startLevel, LevelError, completeLevel) 69 | if len(errHandlers) > 0 { 70 | r.ErrorHandler = errHandlers[0] 71 | } 72 | return r 73 | } 74 | -------------------------------------------------------------------------------- /examples/with-sqlc/tutorial/query.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.28.0 4 | // source: query.sql 5 | 6 | package tutorial 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const createAuthor = `-- name: CreateAuthor :one 14 | INSERT INTO authors ( 15 | name, bio 16 | ) VALUES ( 17 | ?, ? 18 | ) 19 | RETURNING id, name, bio 20 | ` 21 | 22 | type CreateAuthorParams struct { 23 | Name string 24 | Bio sql.NullString 25 | } 26 | 27 | func (q *Queries) CreateAuthor(ctx context.Context, arg CreateAuthorParams) (Author, error) { 28 | row := q.db.QueryRowContext(ctx, createAuthor, arg.Name, arg.Bio) 29 | var i Author 30 | err := row.Scan(&i.ID, &i.Name, &i.Bio) 31 | return i, err 32 | } 33 | 34 | const deleteAuthor = `-- name: DeleteAuthor :exec 35 | DELETE FROM authors 36 | WHERE id = ? 37 | ` 38 | 39 | func (q *Queries) DeleteAuthor(ctx context.Context, id int64) error { 40 | _, err := q.db.ExecContext(ctx, deleteAuthor, id) 41 | return err 42 | } 43 | 44 | const getAuthor = `-- name: GetAuthor :one 45 | SELECT id, name, bio FROM authors 46 | WHERE id = ? LIMIT 1 47 | ` 48 | 49 | func (q *Queries) GetAuthor(ctx context.Context, id int64) (Author, error) { 50 | row := q.db.QueryRowContext(ctx, getAuthor, id) 51 | var i Author 52 | err := row.Scan(&i.ID, &i.Name, &i.Bio) 53 | return i, err 54 | } 55 | 56 | const listAuthors = `-- name: ListAuthors :many 57 | SELECT id, name, bio FROM authors 58 | ORDER BY name 59 | ` 60 | 61 | func (q *Queries) ListAuthors(ctx context.Context) ([]Author, error) { 62 | rows, err := q.db.QueryContext(ctx, listAuthors) 63 | if err != nil { 64 | return nil, err 65 | } 66 | defer rows.Close() 67 | var items []Author 68 | for rows.Next() { 69 | var i Author 70 | if err := rows.Scan(&i.ID, &i.Name, &i.Bio); err != nil { 71 | return nil, err 72 | } 73 | items = append(items, i) 74 | } 75 | if err := rows.Close(); err != nil { 76 | return nil, err 77 | } 78 | if err := rows.Err(); err != nil { 79 | return nil, err 80 | } 81 | return items, nil 82 | } 83 | 84 | const updateAuthor = `-- name: UpdateAuthor :exec 85 | UPDATE authors 86 | set name = ?, 87 | bio = ? 88 | WHERE id = ? 89 | ` 90 | 91 | type UpdateAuthorParams struct { 92 | Name string 93 | Bio sql.NullString 94 | ID int64 95 | } 96 | 97 | func (q *Queries) UpdateAuthor(ctx context.Context, arg UpdateAuthorParams) error { 98 | _, err := q.db.ExecContext(ctx, updateAuthor, arg.Name, arg.Bio, arg.ID) 99 | return err 100 | } 101 | -------------------------------------------------------------------------------- /connector_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "errors" 7 | "io" 8 | "testing" 9 | ) 10 | 11 | func TestConnectorConnectErrorHandler(t *testing.T) { 12 | t.Parallel() 13 | testcases := []string{ 14 | "mysql", 15 | "postgres", 16 | } 17 | for _, driverName := range testcases { 18 | errHandler := ConnectorConnectErrorHandler(driverName) 19 | t.Run(driverName, func(t *testing.T) { 20 | t.Parallel() 21 | t.Run("no error", func(t *testing.T) { 22 | complete, attrs := errHandler(nil) 23 | if !complete { 24 | t.Error("Expected true") 25 | } 26 | if len(attrs) < 1 { 27 | t.Error("Expected non-empty") 28 | } 29 | }) 30 | t.Run("unexpected error", func(t *testing.T) { 31 | t.Parallel() 32 | complete, attrs := errHandler(errors.New("unexpected-error")) 33 | if complete { 34 | t.Fatal("Expected false") 35 | } 36 | if attrs != nil { 37 | t.Fatal("Expected nil") 38 | } 39 | }) 40 | }) 41 | } 42 | 43 | t.Run("postgres io.EOF", func(t *testing.T) { 44 | t.Parallel() 45 | errHandler := ConnectorConnectErrorHandler("postgres") 46 | complete, attrs := errHandler(io.EOF) 47 | if !complete { 48 | t.Fatal("Expected true") 49 | } 50 | if attrs == nil { 51 | t.Fatal("Expected non-nil") 52 | } 53 | }) 54 | t.Run("mysql driver: bad connection", func(t *testing.T) { 55 | t.Parallel() 56 | errHandler := ConnectorConnectErrorHandler("mysql") 57 | complete, attrs := errHandler(errors.New("driver: bad connection")) 58 | if !complete { 59 | t.Fatal("Expected true") 60 | } 61 | if attrs == nil { 62 | t.Fatal("Expected non-nil") 63 | } 64 | }) 65 | t.Run("sqlite3", func(t *testing.T) { 66 | t.Parallel() 67 | errHandler := ConnectorConnectErrorHandler("sqlite3") 68 | if errHandler != nil { 69 | t.Fatal("Expected nil") 70 | } 71 | }) 72 | } 73 | 74 | type mockConnectorForWrapConnector struct{} 75 | 76 | var _ driver.Connector = (*mockConnectorForWrapConnector)(nil) 77 | 78 | func (m *mockConnectorForWrapConnector) Connect(context.Context) (driver.Conn, error) { 79 | panic("unimplemented") 80 | } 81 | 82 | func (m *mockConnectorForWrapConnector) Driver() driver.Driver { 83 | return nil 84 | } 85 | 86 | func TestConnectorDriver(t *testing.T) { 87 | t.Parallel() 88 | mock := &mockConnectorForWrapConnector{} 89 | logger := &stepLogger{} 90 | connectorOptions := defaultConnectorOptions("dummy", StepEventMsgWithoutEventName) 91 | conn := wrapConnector(mock, logger, connectorOptions) 92 | if conn.Driver() != nil { 93 | t.Fatal("Expected nil") 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /stmt_args_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "database/sql/driver" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestFormatNamedValues(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | name string 14 | args []driver.NamedValue 15 | want string 16 | }{ 17 | { 18 | name: "empty", 19 | args: nil, 20 | want: "[]", 21 | }, 22 | { 23 | name: "no names without ordinals", 24 | args: []driver.NamedValue{{Value: "A"}, {Value: 2}}, 25 | want: "[\"A\",2]", 26 | }, 27 | { 28 | name: "no names with sorted ordinals", 29 | args: []driver.NamedValue{{Ordinal: 1, Value: "a"}, {Ordinal: 2, Value: 100}, {Ordinal: 3, Value: 1.234}}, 30 | want: "[\"a\",100,1.234]", 31 | }, 32 | { 33 | name: "no names with unsorted ordinals", 34 | args: []driver.NamedValue{{Ordinal: 2, Value: 100}, {Ordinal: 3, Value: 1.234}, {Ordinal: 1, Value: "a"}}, 35 | want: "[\"a\",100,1.234]", 36 | }, 37 | { 38 | name: "no names with invalid ordinals", 39 | args: []driver.NamedValue{{Ordinal: 2, Value: 100}, {Ordinal: 5, Value: 1.234}, {Ordinal: 0, Value: "a"}}, 40 | want: "[\"a\",100,1.234]", 41 | }, 42 | { 43 | name: "all names", 44 | args: []driver.NamedValue{{Name: "a", Value: 1}, {Name: "b", Value: 2}}, 45 | want: "{a:1,b:2}", 46 | }, 47 | { 48 | name: "mixed names", 49 | args: []driver.NamedValue{{Ordinal: 0, Name: "a", Value: 1}, {Ordinal: 1, Name: "", Value: 2}}, 50 | want: "[[0]a:1,[1]:2]", 51 | }, 52 | } 53 | 54 | for _, tt := range tests { 55 | t.Run(tt.name, func(t *testing.T) { 56 | t.Parallel() 57 | got := formatNamedValues(tt.args) 58 | if got != tt.want { 59 | t.Errorf("formatNamedValues() = %v, want %v", got, tt.want) 60 | } 61 | }) 62 | } 63 | } 64 | 65 | func TestFormatValue(t *testing.T) { 66 | t.Parallel() 67 | 68 | tests := []struct { 69 | value driver.Value 70 | want string 71 | }{ 72 | {value: nil, want: ""}, 73 | {value: true, want: "true"}, 74 | {value: false, want: "false"}, 75 | {value: 123, want: "123"}, 76 | {value: 1.23, want: "1.23"}, 77 | {value: "test", want: "\"test\""}, 78 | {value: []byte("bytes"), want: "\"bytes\""}, 79 | {value: []int{1, 2, 3}, want: "[1 2 3]"}, 80 | {value: map[string]int{"a": 1, "b": 2}, want: "map[a:1 b:2]"}, 81 | {value: struct{ A int }{A: 1}, want: "{1}"}, 82 | {value: time.Date(2023, 10, 1, 12, 0, 0, 0, time.UTC), want: "2023-10-01 12:00:00 +0000 UTC"}, 83 | } 84 | 85 | for _, tt := range tests { 86 | t.Run(tt.want, func(t *testing.T) { 87 | t.Parallel() 88 | got := formatValue(tt.value) 89 | if got != tt.want { 90 | t.Errorf("formatValue(%v) = %v, want %v", tt.value, got, tt.want) 91 | } 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /driver_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "bytes" 5 | "database/sql/driver" 6 | "errors" 7 | "io" 8 | "log/slog" 9 | "testing" 10 | ) 11 | 12 | type mockErrorDiverContext struct{} 13 | 14 | // Open implements driver.Driver. 15 | func (m *mockErrorDiverContext) Open(string) (driver.Conn, error) { 16 | return nil, errors.New("unexpected error") 17 | } 18 | 19 | // OpenConnector implements driver.DriverContext. 20 | func (m *mockErrorDiverContext) OpenConnector(string) (driver.Connector, error) { 21 | return nil, errors.New("unexpected error") 22 | } 23 | 24 | var ( 25 | _ driver.Driver = (*mockErrorDiverContext)(nil) 26 | _ driver.DriverContext = (*mockErrorDiverContext)(nil) 27 | ) 28 | 29 | func TestDriverContextWrapperOpenConnector(t *testing.T) { 30 | t.Parallel() 31 | t.Run("unexpected error", func(t *testing.T) { 32 | t.Parallel() 33 | buf := bytes.NewBuffer(nil) 34 | logger := slog.New(NewTextHandler(buf, nil)) 35 | dw := wrapDriver(&mockErrorDiverContext{}, 36 | newStepLogger(logger, defaultStepLoggerOptions()), 37 | defaultDriverOptions("sqlite3", StepEventMsgWithoutEventName), 38 | ) 39 | dwc, ok := dw.(driver.DriverContext) 40 | if !ok { 41 | t.Fatal("expected to be driver.DriverContext") 42 | } 43 | _, err := dwc.OpenConnector("dsn") 44 | if err == nil { 45 | t.Fatal("expected error to be not nil") 46 | } 47 | }) 48 | } 49 | 50 | func TestDriverOpenErrorHandler(t *testing.T) { 51 | t.Parallel() 52 | t.Run("postgres", func(t *testing.T) { 53 | t.Parallel() 54 | t.Run("no error", func(t *testing.T) { 55 | t.Parallel() 56 | eh := DriverOpenErrorHandler("postgres") 57 | completed, attrs := eh(nil) 58 | if !completed { 59 | t.Error("expected completed to be false") 60 | } 61 | if attrs == nil { 62 | t.Error("expected attrs not to be nil") 63 | } 64 | }) 65 | t.Run("unexpected error", func(t *testing.T) { 66 | t.Parallel() 67 | eh := DriverOpenErrorHandler("postgres") 68 | completed, attrs := eh(errors.New("unexpected error")) 69 | if completed { 70 | t.Error("expected completed to be false") 71 | } 72 | if attrs != nil { 73 | t.Error("expected attrs to be nil") 74 | } 75 | }) 76 | t.Run("io.EOF", func(t *testing.T) { 77 | t.Parallel() 78 | eh := DriverOpenErrorHandler("postgres") 79 | completed, attrs := eh(io.EOF) 80 | if !completed { 81 | t.Errorf("expected completed to be true") 82 | } 83 | if attrs == nil { 84 | t.Error("expected attrs to be non-nil") 85 | } 86 | }) 87 | }) 88 | t.Run("mysql", func(t *testing.T) { 89 | t.Parallel() 90 | eh := DriverOpenErrorHandler("mysql") 91 | if eh != nil { 92 | t.Error("expected to be nil") 93 | } 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /connector.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "errors" 7 | "io" 8 | "log/slog" 9 | ) 10 | 11 | type connectorOptions struct { 12 | Connect StepOptions 13 | ConnOptions *connOptions 14 | } 15 | 16 | func defaultConnectorOptions(driver string, msgb StepEventMsgBuilder) *connectorOptions { 17 | return &connectorOptions{ 18 | Connect: *defaultStepOptions(msgb, StepConnectorConnect, LevelInfo), 19 | ConnOptions: defaultConnOptions(driver, msgb), 20 | } 21 | } 22 | 23 | type connector struct { 24 | original driver.Connector 25 | logger *stepLogger 26 | options *connectorOptions 27 | } 28 | 29 | var _ driver.Connector = (*connector)(nil) 30 | 31 | func wrapConnector(original driver.Connector, logger *stepLogger, options *connectorOptions) driver.Connector { 32 | return &connector{original: original, logger: logger, options: options} 33 | } 34 | 35 | // Connect implements driver.Connector. 36 | func (c *connector) Connect(ctx context.Context) (driver.Conn, error) { 37 | var origConn driver.Conn 38 | err := ignoreAttr(c.logger.Step(ctx, &c.options.Connect, func() (*slog.Attr, error) { 39 | var err error 40 | origConn, err = c.original.Connect(ctx) 41 | return nil, err 42 | })) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return wrapConn(origConn, c.logger, c.options.ConnOptions), nil 48 | } 49 | 50 | // Driver implements driver.Connector. 51 | func (c *connector) Driver() driver.Driver { 52 | return c.original.Driver() 53 | } 54 | 55 | // ConnectorConnectErrorHandler returns a function that handles errors from driver.Connector.Connect. 56 | // The function returns a boolean indicating completion and a slice of slog.Attr. 57 | // 58 | // # For Postgres: 59 | // If err is nil, it returns true and a slice of slog.Attr{slog.Bool("success", true)}. 60 | // If err is io.EOF, it returns true and a slice of slog.Attr{slog.Bool("success", false)}. 61 | // Otherwise, it returns false and nil. 62 | func ConnectorConnectErrorHandler(driverName string) func(err error) (bool, []slog.Attr) { 63 | switch driverName { 64 | case "mysql": 65 | return func(err error) (bool, []slog.Attr) { 66 | if err == nil { 67 | return true, []slog.Attr{slog.Bool("success", true)} 68 | } 69 | if err.Error() == "driver: bad connection" { 70 | return true, []slog.Attr{slog.Bool("success", false)} 71 | } 72 | return false, nil 73 | } 74 | case "postgres": 75 | return func(err error) (bool, []slog.Attr) { 76 | if err == nil { 77 | return true, []slog.Attr{slog.Bool("success", true)} 78 | } 79 | if errors.Is(err, io.EOF) { 80 | return true, []slog.Attr{slog.Bool("success", false)} 81 | } 82 | return false, nil 83 | } 84 | default: 85 | return nil 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /factory.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "database/sql/driver" 7 | "log/slog" 8 | ) 9 | 10 | type Factory struct { 11 | options *options 12 | driverName string 13 | dsn string 14 | 15 | handler slog.Handler 16 | logger *slog.Logger 17 | } 18 | 19 | func New(driverName, dsn string, opts ...Option) *Factory { 20 | options := newOptions(driverName, opts...) 21 | return &Factory{ 22 | driverName: driverName, 23 | dsn: dsn, 24 | options: options, 25 | handler: options.SlogOptions.handler, 26 | } 27 | } 28 | 29 | func (f *Factory) newHandler() slog.Handler { 30 | o := f.options.SlogOptions 31 | return o.handlerFunc(o.logWriter, &o.HandlerOptions) 32 | } 33 | 34 | func (f *Factory) Handler() slog.Handler { 35 | if f.handler == nil { 36 | f.handler = f.newHandler() 37 | } 38 | return f.handler 39 | } 40 | 41 | func (f *Factory) Logger() *slog.Logger { 42 | if f.logger == nil { 43 | f.logger = slog.New(f.Handler()) 44 | } 45 | return f.logger 46 | } 47 | 48 | func (f *Factory) Open(ctx context.Context) (*sql.DB, error) { 49 | stepLogger := newStepLogger(f.Logger(), f.options.stepLoggerOptions) 50 | return open(ctx, f.driverName, f.dsn, stepLogger, f.options) 51 | } 52 | 53 | func open(ctx context.Context, driverName, dsn string, logger *stepLogger, options *options) (*sql.DB, error) { 54 | lg := logger.With( 55 | slog.String("driver", driverName), 56 | slog.String("dsn", dsn), 57 | ) 58 | 59 | var db *sql.DB 60 | err := ignoreAttr(lg.Step(ctx, &options.Open, func() (*slog.Attr, error) { 61 | var err error 62 | db, err = openWithDriver(driverName, dsn, logger, options.DriverOptions) 63 | return nil, err 64 | })) 65 | if err != nil { 66 | return nil, err 67 | } 68 | return db, nil 69 | } 70 | 71 | func openWithDriver(driverName, dsn string, logger *stepLogger, driverOptions *driverOptions) (*sql.DB, error) { 72 | db, err := sql.Open(driverName, dsn) 73 | if err != nil { 74 | return nil, err 75 | } 76 | // This db is not used directly, but it is used to get the driver. 77 | 78 | drv := wrapDriver(db.Driver(), logger, driverOptions) 79 | 80 | return openWithWrappedDriver(drv, dsn, logger, driverOptions) 81 | } 82 | 83 | func openWithWrappedDriver(drv driver.Driver, dsn string, logger *stepLogger, driverOptions *driverOptions) (*sql.DB, error) { 84 | var origConnector driver.Connector 85 | 86 | if dc, ok := drv.(driver.DriverContext); ok { 87 | connector, err := dc.OpenConnector(dsn) 88 | if err != nil { 89 | return nil, err 90 | } 91 | origConnector = connector 92 | } else { 93 | origConnector = &dsnConnector{dsn: dsn, driver: drv} 94 | } 95 | 96 | return sql.OpenDB(wrapConnector(origConnector, logger, driverOptions.ConnectorOptions)), nil 97 | } 98 | -------------------------------------------------------------------------------- /slog_example_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog_test 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | 8 | sqlslog "github.com/akm/sql-slog" 9 | // _ "github.com/mattn/go-sqlite3" 10 | ) 11 | 12 | func removeTimeAndDuration(groups []string, a slog.Attr) slog.Attr { 13 | if len(groups) == 0 { 14 | switch a.Key { 15 | case slog.TimeKey: 16 | return slog.Attr{} 17 | case "duration": 18 | return slog.Attr{} 19 | } 20 | } 21 | return a 22 | } 23 | 24 | func ExampleNewJSONHandler() { 25 | dsn := "dummy-dsn" 26 | ctx := context.TODO() 27 | db, logger, _ := sqlslog.Open(ctx, "mock", dsn, 28 | sqlslog.HandlerFunc(sqlslog.NewJSONHandler), 29 | sqlslog.LogReplaceAttr(removeTimeAndDuration), 30 | ) 31 | defer db.Close() 32 | logger.InfoContext(ctx, "Hello, World!") 33 | 34 | // Output: 35 | // {"level":"INFO","msg":"Open","driver":"mock","dsn":"dummy-dsn"} 36 | // {"level":"INFO","msg":"Hello, World!"} 37 | } 38 | 39 | func ExampleNewTextHandler() { 40 | dsn := "dummy-dsn" 41 | ctx := context.TODO() 42 | db, logger, _ := sqlslog.Open(ctx, "mock", dsn, 43 | sqlslog.HandlerFunc(sqlslog.NewTextHandler), 44 | sqlslog.LogReplaceAttr(removeTimeAndDuration), 45 | ) 46 | defer db.Close() 47 | logger.InfoContext(ctx, "Hello, World!") 48 | 49 | // Output: 50 | // level=INFO msg=Open driver=mock dsn=dummy-dsn 51 | // level=INFO msg="Hello, World!" 52 | } 53 | 54 | func ExampleHandler() { 55 | // Normal slog.Handler with sqlslog.ReplaceLevelAttr knows how to show LevelTrace and LevelVerbose. 56 | handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 57 | ReplaceAttr: sqlslog.MergeReplaceAttrs( 58 | sqlslog.ReplaceLevelAttr, 59 | removeTimeAndDuration, // for testing 60 | ), 61 | Level: sqlslog.LevelVerbose, 62 | }) 63 | 64 | dsn := "dummy-dsn" 65 | ctx := context.TODO() 66 | 67 | logger := sqlslog.New("mock", dsn, sqlslog.Handler(handler)).Logger() 68 | logger.Log(ctx, slog.Level(sqlslog.LevelTrace), "Foo") 69 | logger.Log(ctx, slog.Level(sqlslog.LevelVerbose), "Bar") 70 | 71 | // Output: 72 | // level=TRACE msg=Foo 73 | // level=VERBOSE msg=Bar 74 | } 75 | 76 | func ExampleHandler_withoutReplaceLevelAttr() { 77 | // Normal slog.Handler without sqlslog.ReplaceLevelAttr does not know how to show LevelTrace and LevelVerbose. 78 | handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 79 | ReplaceAttr: removeTimeAndDuration, 80 | Level: sqlslog.LevelVerbose, 81 | }) 82 | 83 | dsn := "dummy-dsn" 84 | ctx := context.TODO() 85 | logger := sqlslog.New("mock", dsn, sqlslog.Handler(handler)).Logger() 86 | 87 | logger.Log(ctx, slog.Level(sqlslog.LevelTrace), "Foo") 88 | logger.Log(ctx, slog.Level(sqlslog.LevelVerbose), "Bar") 89 | 90 | // Output: 91 | // level=DEBUG-4 msg=Foo 92 | // level=DEBUG-8 msg=Bar 93 | } 94 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/results/trace-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Open driver=sqlite3 dsn="file::memory:?cache=shared" 2 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Open driver=sqlite3 dsn="file::memory:?cache=shared" duration=9916 3 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Connector.Connect 4 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Driver.Open dsn="file::memory:?cache=shared" 5 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Driver.Open dsn="file::memory:?cache=shared" duration=191083 conn_id=jAG1uDtyIgttUGsW 6 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Connector.Connect duration=202875 7 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Conn.ExecContext conn_id=jAG1uDtyIgttUGsW query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] 8 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Conn.ExecContext conn_id=jAG1uDtyIgttUGsW query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] duration=41125 9 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Conn.ResetSession conn_id=jAG1uDtyIgttUGsW duration=291 10 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Conn.BeginTx conn_id=jAG1uDtyIgttUGsW 11 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Conn.BeginTx conn_id=jAG1uDtyIgttUGsW duration=2625 tx_id=OQCmMz6s2uwwxiV0 12 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Conn.ExecContext conn_id=jAG1uDtyIgttUGsW query="INSERT INTO test1 (name) VALUES (?)" args="[\"Alice\"]" 13 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Conn.ExecContext conn_id=jAG1uDtyIgttUGsW query="INSERT INTO test1 (name) VALUES (?)" args="[\"Alice\"]" duration=8542 14 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Tx.Commit conn_id=jAG1uDtyIgttUGsW tx_id=OQCmMz6s2uwwxiV0 15 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Tx.Commit conn_id=jAG1uDtyIgttUGsW tx_id=OQCmMz6s2uwwxiV0 duration=3125 16 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Conn.ResetSession conn_id=jAG1uDtyIgttUGsW duration=0 17 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Conn.QueryContext conn_id=jAG1uDtyIgttUGsW query="SELECT * FROM test1" args=[] 18 | time=2025-08-06T23:23:51.944+09:00 level=DEBUG msg=Conn.QueryContext conn_id=jAG1uDtyIgttUGsW query="SELECT * FROM test1" args=[] duration=4375 19 | time=2025-08-06T23:23:51.944+09:00 level=DEBUG msg=Rows.Next conn_id=jAG1uDtyIgttUGsW duration=2708 eof=false 20 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Record id=1 name=Alice 21 | time=2025-08-06T23:23:51.944+09:00 level=DEBUG msg=Rows.Next conn_id=jAG1uDtyIgttUGsW duration=625 eof=true 22 | time=2025-08-06T23:23:51.944+09:00 level=DEBUG msg=Rows.Close conn_id=jAG1uDtyIgttUGsW duration=375 23 | time=2025-08-06T23:23:51.944+09:00 level=TRACE msg=Conn.Close conn_id=jAG1uDtyIgttUGsW 24 | time=2025-08-06T23:23:51.944+09:00 level=INFO msg=Conn.Close conn_id=jAG1uDtyIgttUGsW duration=9458 25 | -------------------------------------------------------------------------------- /step_logger_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestStepLoggerDurationAttr(t *testing.T) { 10 | t.Parallel() 11 | key := "duration" 12 | testcases := []struct { 13 | value time.Duration 14 | durationType DurationType 15 | expected func(t *testing.T, attr slog.Attr) 16 | }{ 17 | { 18 | value: time.Duration(1), 19 | durationType: DurationNanoSeconds, 20 | expected: func(t *testing.T, attr slog.Attr) { 21 | t.Helper() 22 | if v, ok := attr.Value.Any().(int64); !ok { 23 | t.Errorf("expected: %T, but got %T", int64(0), v) 24 | } else if v != 1 { 25 | t.Errorf("expected: %d, but got %d", 1, v) 26 | } 27 | }, 28 | }, 29 | { 30 | value: time.Duration(2_000), 31 | durationType: DurationMicroSeconds, 32 | expected: func(t *testing.T, attr slog.Attr) { 33 | t.Helper() 34 | if v, ok := attr.Value.Any().(int64); !ok { 35 | t.Errorf("expected: %T, but got %T", int64(0), v) 36 | } else if v != 2 { 37 | t.Errorf("expected: %d, but got %d", 2, v) 38 | } 39 | }, 40 | }, 41 | { 42 | value: time.Duration(3_000_000), 43 | durationType: DurationMilliSeconds, 44 | expected: func(t *testing.T, attr slog.Attr) { 45 | t.Helper() 46 | if v, ok := attr.Value.Any().(int64); !ok { 47 | t.Errorf("expected: %T, but got %T", int64(0), v) 48 | } else if v != 3 { 49 | t.Errorf("expected: %d, but got %d", 3, v) 50 | } 51 | }, 52 | }, 53 | { 54 | value: time.Duration(4_000_000_000), 55 | durationType: DurationGoDuration, 56 | expected: func(t *testing.T, attr slog.Attr) { 57 | t.Helper() 58 | if attr.Value.Duration() != time.Duration(4_000_000_000) { 59 | t.Errorf("expected: %d, but got %d", 4_000_000_000, attr.Value.Duration()) 60 | } 61 | }, 62 | }, 63 | { 64 | value: time.Duration(567_000_000), 65 | durationType: DurationString, 66 | expected: func(t *testing.T, attr slog.Attr) { 67 | t.Helper() 68 | if v, ok := attr.Value.Any().(string); !ok { 69 | t.Errorf("expected: %T, but got %T", "", v) 70 | } else if v != "567ms" { 71 | t.Errorf("expected: %s, but got %s", "567ms", v) 72 | } 73 | }, 74 | }, 75 | { 76 | value: time.Duration(890_000), 77 | durationType: DurationType(-1), 78 | expected: func(t *testing.T, attr slog.Attr) { 79 | t.Helper() 80 | if v, ok := attr.Value.Any().(int64); !ok { 81 | t.Errorf("expected: %T, but got %T", int64(0), v) 82 | } else if v != 890_000 { 83 | t.Errorf("expected: %d, but got %d", 890_000, v) 84 | } 85 | }, 86 | }, 87 | } 88 | for _, tc := range testcases { 89 | t.Run(tc.value.String(), func(t *testing.T) { 90 | t.Parallel() 91 | attr := newStepLogger(slog.Default(), stepLoggerOptions{durationKey: key, durationType: tc.durationType}).durationAttr(tc.value) 92 | tc.expected(t, attr) 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /id_gen.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import "math/rand/v2" 4 | 5 | // IDGen is a function that generates an ID string. 6 | type IDGen = func() string 7 | 8 | // IDGenerator returns an Option that sets the ID generator. 9 | // The default is IDGeneratorDefault. 10 | func IDGenerator(idGen IDGen) Option { 11 | return func(o *options) { 12 | o.DriverOptions.IDGen = idGen 13 | o.DriverOptions.ConnOptions.IDGen = idGen 14 | } 15 | } 16 | 17 | const ( 18 | ConnIDKeyDefault = "conn_id" 19 | TxIDKeyDefault = "tx_id" 20 | StmtIDKeyDefault = "stmt_id" 21 | ) 22 | 23 | // ConnIDKey sets the key for the connection ID. 24 | // The default is ConnIDKeyDefault. 25 | func ConnIDKey(key string) Option { 26 | return func(o *options) { 27 | o.DriverOptions.ConnIDKey = key 28 | } 29 | } 30 | 31 | // TxIDKey sets the key for the transaction ID. 32 | // The default is TxIDKeyDefault. 33 | func TxIDKey(key string) Option { 34 | return func(o *options) { o.DriverOptions.ConnOptions.TxIDKey = key } 35 | } 36 | 37 | // StmtIDKey sets the key for the statement ID. 38 | // The default is StmtIDKeyDefault. 39 | func StmtIDKey(key string) Option { 40 | return func(o *options) { o.DriverOptions.ConnOptions.StmtIDKey = key } 41 | } 42 | 43 | // Returns a random ID generator that generates a string of length characters 44 | // using randInt to generate random integers such as Int function from math/rand/v2 package. 45 | func RandIntIDGenerator( 46 | randInt func() int, 47 | letters []byte, 48 | length int, 49 | ) IDGen { 50 | lenLetters := len(letters) 51 | return func() string { 52 | b := make([]byte, length) 53 | for i := range b { 54 | b[i] = letters[randInt()%lenLetters] 55 | } 56 | return string(b) 57 | } 58 | } 59 | 60 | // Returns a random ID generator that generates a string of length characters 61 | // using randRead to generate random bytes such as Read function from crypto/rand package. 62 | func RandReadIDGenerator( 63 | randRead func(b []byte) (n int, err error), 64 | letters []byte, 65 | length int, 66 | ) func() (string, error) { 67 | lenLetters := len(letters) 68 | return func() (string, error) { 69 | b := make([]byte, length) 70 | if _, err := randRead(b); err != nil { 71 | return "", err 72 | } 73 | for i := range b { 74 | b[i] = letters[int(b[i])%lenLetters] 75 | } 76 | return string(b), nil 77 | } 78 | } 79 | 80 | // IDGenErrorSuppressor returns an ID generator that suppresses errors. 81 | // If an error occurs, the recover function is called with the error and the result is returned. 82 | func IDGenErrorSuppressor(idGen func() (string, error), recoveryFunc func(error) string) IDGen { 83 | return func() string { 84 | id, err := idGen() 85 | if err != nil { 86 | return recoveryFunc(err) 87 | } 88 | return id 89 | } 90 | } 91 | 92 | const defaultIDLength = 16 93 | 94 | var defaultIDLetters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_") 95 | 96 | // IDGeneratorDefault is the default ID generator. 97 | var IDGeneratorDefault = RandIntIDGenerator(rand.Int, defaultIDLetters, defaultIDLength) 98 | -------------------------------------------------------------------------------- /tests/testhelper/logs_assertion.go: -------------------------------------------------------------------------------- 1 | package testhelper 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type LogsAssertion struct { 12 | buf *bytes.Buffer 13 | } 14 | 15 | func NewLogAssertion(buf *bytes.Buffer) *LogsAssertion { 16 | return &LogsAssertion{buf: buf} 17 | } 18 | 19 | func (a *LogsAssertion) Start() { 20 | a.buf.Reset() 21 | } 22 | 23 | func (a *LogsAssertion) JsonLines(t *testing.T) []map[string]interface{} { 24 | return parseJsonLines(t, a.buf.Bytes()) 25 | } 26 | 27 | func (a *LogsAssertion) Assert(t *testing.T, expected []map[string]interface{}) { 28 | actual := a.JsonLines(t) 29 | assertMapSlice(t, expected, actual, 30 | ignore("time"), 31 | when(msgEndsWith(" Complete"), deleteIfFloat64("duration")), 32 | when(msgEndsWith(" Error"), deleteIfFloat64("duration")), 33 | ) 34 | } 35 | 36 | func (a *LogsAssertion) AssertEmpty(t *testing.T) { 37 | a.Assert(t, []map[string]interface{}{}) 38 | } 39 | 40 | func parseJsonLines(t *testing.T, b []byte) []map[string]interface{} { 41 | t.Helper() 42 | lines := bytes.Split(b, []byte("\n")) 43 | results := []map[string]interface{}{} 44 | for _, line := range lines { 45 | if len(line) == 0 { 46 | continue 47 | } 48 | result := map[string]interface{}{} 49 | if err := json.Unmarshal(line, &result); err != nil { 50 | t.Fatalf("Failed to unmarshal JSON: %v", err) 51 | } 52 | results = append(results, result) 53 | } 54 | return results 55 | } 56 | 57 | type processor = func(t *testing.T, m map[string]interface{}) 58 | 59 | func ignore(fields ...string) processor { 60 | return func(_ *testing.T, m map[string]interface{}) { 61 | for _, f := range fields { 62 | delete(m, f) 63 | } 64 | } 65 | } 66 | 67 | func when(predicate func(map[string]interface{}) bool, processors ...processor) processor { 68 | return func(t *testing.T, m map[string]interface{}) { 69 | if predicate(m) { 70 | for _, p := range processors { 71 | p(t, m) 72 | } 73 | } 74 | } 75 | } 76 | 77 | func msgEndsWith(suffix string) func(map[string]interface{}) bool { 78 | return func(m map[string]interface{}) bool { 79 | msg, ok := m["msg"].(string) 80 | return ok && msg[len(msg)-len(suffix):] == suffix 81 | } 82 | } 83 | 84 | // Parsed value from JSON is float64 85 | func deleteIfFloat64(key string) processor { 86 | return func(t *testing.T, m map[string]interface{}) { 87 | if _, ok := m[key].(float64); ok { 88 | delete(m, key) 89 | } 90 | } 91 | } 92 | 93 | func assertMapSlice(t *testing.T, expected, actual []map[string]interface{}, processors ...processor) { 94 | t.Helper() 95 | comparedSlice := []map[string]interface{}{} 96 | for _, a := range actual { 97 | compared := map[string]interface{}{} 98 | for k, v := range a { 99 | compared[k] = v 100 | } 101 | for _, p := range processors { 102 | p(t, compared) 103 | } 104 | comparedSlice = append(comparedSlice, compared) 105 | } 106 | assert.Equal(t, expected, comparedSlice) 107 | } 108 | -------------------------------------------------------------------------------- /rows_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "bytes" 5 | "database/sql/driver" 6 | "errors" 7 | "log/slog" 8 | "testing" 9 | ) 10 | 11 | func TestWrapRows(t *testing.T) { 12 | t.Parallel() 13 | if wrapRows(nil, nil, nil) != nil { 14 | t.Fatal("Expected nil") 15 | } 16 | } 17 | 18 | type mockRows struct{} 19 | 20 | var _ driver.Rows = (*mockRows)(nil) 21 | 22 | // Close implements driver.Rows. 23 | func (m *mockRows) Close() error { 24 | panic("unimplemented") 25 | } 26 | 27 | // Columns implements driver.Rows. 28 | func (m *mockRows) Columns() []string { 29 | panic("unimplemented") 30 | } 31 | 32 | // Next implements driver.Rows. 33 | func (m *mockRows) Next([]driver.Value) error { 34 | panic("unimplemented") 35 | } 36 | 37 | func TestWithMockRows(t *testing.T) { 38 | t.Parallel() 39 | wrapped := &rowsWrapper{original: &mockRows{}, logger: newStepLogger(slog.Default(), defaultStepLoggerOptions())} 40 | t.Run("ColumnTypeScanType", func(t *testing.T) { 41 | t.Parallel() 42 | res := wrapped.ColumnTypeScanType(0) 43 | if res == nil { 44 | t.Fatal("Expected non-nil") 45 | } 46 | }) 47 | t.Run("ColumnTypeDatabaseTypeName", func(t *testing.T) { 48 | t.Parallel() 49 | res := wrapped.ColumnTypeDatabaseTypeName(0) 50 | if res != "" { 51 | t.Fatal("Expected empty") 52 | } 53 | }) 54 | } 55 | 56 | type mockRowsNextResultSet struct { 57 | mockRows 58 | error error 59 | } 60 | 61 | func (m *mockRowsNextResultSet) Close() error { 62 | return m.error 63 | } 64 | 65 | func (m *mockRowsNextResultSet) Columns() []string { 66 | panic("unimplemented") 67 | } 68 | 69 | func (m *mockRowsNextResultSet) HasNextResultSet() bool { 70 | panic("unimplemented") 71 | } 72 | 73 | func (m *mockRowsNextResultSet) Next([]driver.Value) error { 74 | return m.error 75 | } 76 | 77 | func (m *mockRowsNextResultSet) NextResultSet() error { 78 | return m.error 79 | } 80 | 81 | var _ driver.RowsNextResultSet = (*mockRowsNextResultSet)(nil) 82 | 83 | func TestRowsNextResultSet(t *testing.T) { 84 | t.Parallel() 85 | errMsg := "unpected RNRS error" 86 | rows := &mockRowsNextResultSet{ 87 | mockRows: mockRows{}, 88 | error: errors.New(errMsg), 89 | } 90 | buf := bytes.NewBuffer(nil) 91 | logger := slog.New(NewJSONHandler(buf, nil)) 92 | rowsOptions := defaultRowsOptions(StepEventMsgWithoutEventName) 93 | wrapped := wrapRows(rows, newStepLogger(logger, defaultStepLoggerOptions()), rowsOptions) 94 | wrappedRNRS, ok := wrapped.(driver.RowsNextResultSet) 95 | if !ok { 96 | t.Fatal("Expected true") 97 | } 98 | err := wrappedRNRS.NextResultSet() 99 | if err == nil { 100 | t.Fatal("Expected non-nil") 101 | } 102 | if err.Error() != errMsg { 103 | t.Fatalf("Expected %q, got %q", errMsg, err.Error()) 104 | } 105 | } 106 | 107 | func TestHandleRowsNextError(t *testing.T) { 108 | t.Parallel() 109 | complete, attrs := HandleRowsNextError(errors.New("dummy")) 110 | if complete { 111 | t.Fatal("Expected false") 112 | } 113 | if attrs != nil { 114 | t.Fatal("Expected nil") 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | sqlslog is a logger for Go SQL database drivers without modifying existing [*sql.DB] stdlib usage. 3 | sqlslog uses [*slog.Logger] to log SQL database driver operations. 4 | 5 | # How to use 6 | 7 | db, logger, err := sqlslog.Open(ctx, "mysql", dsn) 8 | 9 | You can also use options to customize the logger's behavior. 10 | 11 | [Open] takes [Option] s to customize the logging behavior. 12 | [Option] is created by using functions like [HandlerFunc], [ConnPrepareContext], [StmtQueryContext], etc. 13 | 14 | # HandlerFunc / Handler 15 | 16 | [HandlerFunc] sets the function to create a [slog.Handler] for the logger. 17 | slogslog provides [NewTextHandler] and [NewJSONHandler] to create a [slog.Handler] for sqlslog. 18 | 19 | You can also use your own [slog.Handler] by using [Handler] with your [slog.Handler]. 20 | But your own [slog.Handler] should know how to log [LevelTrace] and [LevelVerbose] log levels. 21 | So you should use [sqlslog.ReplaceLevelAttr] with your [slog.Handler]. 22 | 23 | # Level 24 | 25 | sqlslog has 6 log levels: [LevelVerbose], [LevelTrace], [LevelDebug], [LevelInfo], [LevelWarn], and [LevelError]. 26 | [LevelDebug], [LevelInfo], [LevelWarn], and [LevelError] are the same as slog's log levels. 27 | [LevelVerbose] and [LevelTrace] are extra log levels for sqlslog. 28 | [LevelVerbose] is the lowest log level, and [LevelTrace] is the second lowest log level. 29 | 30 | # Step and Event 31 | 32 | A [Step] is a logical operation in the database driver, such as a query, a ping, a prepare, etc. 33 | An [Event] is an event that occurs during a [Step], such as [EventStart], [EventError], and [EventComplete]. 34 | A [StepOptions] is a set of options for logging a [Step] and has [EventOptions] for each event. 35 | sqlslog provides a way to customize the log message and log [Level] for each step event. 36 | You can customize them by using functions that take [StepOptions] and return [Option], like [ConnPrepareContext] or [StmtQueryContext]. 37 | 38 | # DefaultStepEventMsgBuilder 39 | 40 | The default step event message builder is [StepEventMsgWithEventName]. 41 | You can change the default step event message builder by calling [SetStepEventMsgBuilder]. 42 | 43 | # Duration 44 | 45 | sqlslog measures the duration of each step and logs it. 46 | You can change the duration unit by calling [Duration] function and can change 47 | the key name of the duration by calling [DurationKey] function. 48 | 49 | # Tracking ID 50 | 51 | sqlslog provides a way to track connections, transactions and statements by using a tracking ID. 52 | Each tracking ID is a unique identifier for a connection, transaction or statement. 53 | Tracking ID key in logs is conn_id, tx_id or stmt_id. 54 | Tracking IDs are generated by the ID generator function. The default ID generator function is [IDGeneratorDefault]. 55 | You can change the ID generator function by calling [IDGenerator] with functions created by [RandIntIDGenerator] or 56 | [RandReadIDGenerator] with [IDGenErrorSuppressor]. 57 | 58 | [*sql.DB]: https://pkg.go.dev/database/sql#DB 59 | [*slog.Logger]: https://pkg.go.dev/log/slog#Logger 60 | [slog.Handler]: https://pkg.go.dev/log/slog#Handler 61 | */ 62 | package sqlslog 63 | -------------------------------------------------------------------------------- /step_logger.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | ) 8 | 9 | type stepLoggerOptions struct { 10 | durationKey string 11 | durationType DurationType 12 | } 13 | 14 | func defaultStepLoggerOptions() stepLoggerOptions { 15 | return stepLoggerOptions{ 16 | durationKey: DurationKeyDefault, 17 | durationType: DurationNanoSeconds, 18 | } 19 | } 20 | 21 | type stepLogger struct { 22 | *slog.Logger 23 | durationAttr func(d time.Duration) slog.Attr 24 | } 25 | 26 | func newStepLogger(logger *slog.Logger, opts stepLoggerOptions) *stepLogger { 27 | return &stepLogger{ 28 | Logger: logger, 29 | durationAttr: durationAttrFunc(opts.durationKey, opts.durationType), 30 | } 31 | } 32 | 33 | func (x *stepLogger) With(kv ...interface{}) *stepLogger { 34 | return &stepLogger{ 35 | Logger: x.Logger.With(kv...), 36 | durationAttr: x.durationAttr, 37 | } 38 | } 39 | 40 | func (x *stepLogger) StepWithoutContext(step *StepOptions, fn func() (*slog.Attr, error)) (*slog.Attr, error) { 41 | return x.Step(context.Background(), step, fn) 42 | } 43 | 44 | func (x *stepLogger) Step(ctx context.Context, step *StepOptions, fn func() (*slog.Attr, error)) (*slog.Attr, error) { 45 | x.Log(ctx, slog.Level(step.Start.Level), step.Start.Msg) 46 | t0 := time.Now() 47 | attr, err := fn() 48 | lg := x.With(x.durationAttr(time.Since(t0))) 49 | var complete bool 50 | if step.ErrorHandler != nil { 51 | var attrs []slog.Attr 52 | complete, attrs = step.ErrorHandler(err) 53 | if len(attrs) > 0 { 54 | args := make([]interface{}, len(attrs)) 55 | for i, attr := range attrs { 56 | args[i] = attr 57 | } 58 | lg = lg.With(args...) 59 | } 60 | } else { 61 | complete = err == nil 62 | } 63 | switch { 64 | case !complete: 65 | lg.Log(ctx, slog.Level(step.Error.Level), step.Error.Msg, slog.Any("error", err)) 66 | case attr != nil: 67 | lg.Log(ctx, slog.Level(step.Complete.Level), step.Complete.Msg, *attr) 68 | default: 69 | lg.Log(ctx, slog.Level(step.Complete.Level), step.Complete.Msg) 70 | } 71 | return attr, err 72 | } 73 | 74 | func durationAttrFunc(key string, dt DurationType) func(d time.Duration) slog.Attr { 75 | switch dt { 76 | case DurationNanoSeconds: 77 | return func(d time.Duration) slog.Attr { return slog.Int64(key, d.Nanoseconds()) } 78 | case DurationMicroSeconds: 79 | return func(d time.Duration) slog.Attr { return slog.Int64(key, d.Microseconds()) } 80 | case DurationMilliSeconds: 81 | return func(d time.Duration) slog.Attr { return slog.Int64(key, d.Milliseconds()) } 82 | case DurationGoDuration: 83 | return func(d time.Duration) slog.Attr { return slog.Duration(key, d) } 84 | case DurationString: 85 | return func(d time.Duration) slog.Attr { return slog.String(key, d.String()) } 86 | default: 87 | return func(d time.Duration) slog.Attr { return slog.Int64(key, d.Nanoseconds()) } 88 | } 89 | } 90 | 91 | func withNilAttr(f func() error) func() (*slog.Attr, error) { 92 | return func() (*slog.Attr, error) { 93 | return nil, f() 94 | } 95 | } 96 | 97 | func ignoreAttr(_ *slog.Attr, err error) error { 98 | return err 99 | } 100 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/results/verbose-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Open driver=sqlite3 dsn="file::memory:?cache=shared" 2 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Open driver=sqlite3 dsn="file::memory:?cache=shared" duration=7334 3 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Connector.Connect 4 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Driver.Open dsn="file::memory:?cache=shared" 5 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Driver.Open dsn="file::memory:?cache=shared" duration=132458 conn_id=h_puYkHhQqD5H2eY 6 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Connector.Connect duration=145083 7 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Conn.ExecContext conn_id=h_puYkHhQqD5H2eY query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] 8 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Conn.ExecContext conn_id=h_puYkHhQqD5H2eY query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] duration=45750 9 | time=2025-08-06T23:23:52.030+09:00 level=VERBOSE msg=Conn.ResetSession conn_id=h_puYkHhQqD5H2eY 10 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Conn.ResetSession conn_id=h_puYkHhQqD5H2eY duration=375 11 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Conn.BeginTx conn_id=h_puYkHhQqD5H2eY 12 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Conn.BeginTx conn_id=h_puYkHhQqD5H2eY duration=2583 tx_id=CmO0XjmAvjYSuYLy 13 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Conn.ExecContext conn_id=h_puYkHhQqD5H2eY query="INSERT INTO test1 (name) VALUES (?)" args="[\"Alice\"]" 14 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Conn.ExecContext conn_id=h_puYkHhQqD5H2eY query="INSERT INTO test1 (name) VALUES (?)" args="[\"Alice\"]" duration=7459 15 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Tx.Commit conn_id=h_puYkHhQqD5H2eY tx_id=CmO0XjmAvjYSuYLy 16 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Tx.Commit conn_id=h_puYkHhQqD5H2eY tx_id=CmO0XjmAvjYSuYLy duration=2709 17 | time=2025-08-06T23:23:52.030+09:00 level=VERBOSE msg=Conn.ResetSession conn_id=h_puYkHhQqD5H2eY 18 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Conn.ResetSession conn_id=h_puYkHhQqD5H2eY duration=42 19 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Conn.QueryContext conn_id=h_puYkHhQqD5H2eY query="SELECT * FROM test1" args=[] 20 | time=2025-08-06T23:23:52.030+09:00 level=DEBUG msg=Conn.QueryContext conn_id=h_puYkHhQqD5H2eY query="SELECT * FROM test1" args=[] duration=4084 21 | time=2025-08-06T23:23:52.030+09:00 level=VERBOSE msg=Rows.Next conn_id=h_puYkHhQqD5H2eY 22 | time=2025-08-06T23:23:52.030+09:00 level=DEBUG msg=Rows.Next conn_id=h_puYkHhQqD5H2eY duration=2583 eof=false 23 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Record id=1 name=Alice 24 | time=2025-08-06T23:23:52.030+09:00 level=VERBOSE msg=Rows.Next conn_id=h_puYkHhQqD5H2eY 25 | time=2025-08-06T23:23:52.030+09:00 level=DEBUG msg=Rows.Next conn_id=h_puYkHhQqD5H2eY duration=750 eof=true 26 | time=2025-08-06T23:23:52.030+09:00 level=VERBOSE msg=Rows.Close conn_id=h_puYkHhQqD5H2eY 27 | time=2025-08-06T23:23:52.030+09:00 level=DEBUG msg=Rows.Close conn_id=h_puYkHhQqD5H2eY duration=375 28 | time=2025-08-06T23:23:52.030+09:00 level=TRACE msg=Conn.Close conn_id=h_puYkHhQqD5H2eY 29 | time=2025-08-06T23:23:52.030+09:00 level=INFO msg=Conn.Close conn_id=h_puYkHhQqD5H2eY duration=9416 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default 2 | default: build lint test 3 | 4 | .PHONY: build 5 | build: 6 | go build ./... 7 | 8 | GOLANG_TOOL_PATH_TO_BIN=$(shell go env GOPATH) 9 | GOLANGCI_LINT_CLI_VERSION?=latest 10 | GOLANGCI_LINT_CLI_MODULE=github.com/golangci/golangci-lint/cmd/golangci-lint 11 | GOLANGCI_LINT_CLI=$(GOLANG_TOOL_PATH_TO_BIN)/bin/golangci-lint 12 | $(GOLANGCI_LINT_CLI): 13 | $(MAKE) golangci-lint-cli-install 14 | golangci-lint-cli-install: 15 | go install $(GOLANGCI_LINT_CLI_MODULE)@$(GOLANGCI_LINT_CLI_VERSION) 16 | 17 | .PHONY: lint 18 | lint: $(GOLANGCI_LINT_CLI) 19 | golangci-lint run 20 | 21 | 22 | GODOC_CLI_VERSION=latest 23 | GODOC_CLI_MODULE=golang.org/x/tools/cmd/godoc 24 | GODOC_CLI=$(GOLANG_TOOL_PATH_TO_BIN)/bin/godoc 25 | $(GODOC_CLI): 26 | $(MAKE) godoc-cli-install 27 | godoc-cli-install: 28 | go install $(GODOC_CLI_MODULE)@$(GODOC_CLI_VERSION) 29 | 30 | .PHONY: godoc 31 | godoc: $(GODOC_CLI) 32 | @echo "Open http://localhost:6060/pkg/github.com/akm/sql-slog" 33 | godoc -http=:6060 34 | 35 | # examples-logs-gen 36 | examples-%: 37 | $(MAKE) -C examples $* 38 | 39 | tests-%: 40 | $(MAKE) -C tests $* 41 | 42 | GO_TEST_OPTIONS?= 43 | 44 | .PHONY: test 45 | test: test-unit tests-run 46 | 47 | .PHONY: test-unit 48 | test-unit: 49 | go test $(GO_TEST_OPTIONS) ./... 50 | 51 | GO_COVERAGE_DIR=coverage/unit 52 | $(GO_COVERAGE_DIR): 53 | mkdir -p $(GO_COVERAGE_DIR) 54 | 55 | GO_COVERAGE_MERGED_DIR=coverage/merged 56 | $(GO_COVERAGE_MERGED_DIR): 57 | mkdir -p $(GO_COVERAGE_MERGED_DIR) 58 | 59 | GO_COVERAGE_HTML?=coverage.html 60 | GO_COVERAGE_PROFILE?=coverage.txt 61 | $(GO_COVERAGE_PROFILE): 62 | $(MAKE) test-coverage-profile 63 | 64 | test-with-coverage: test-with-coverage-unit tests-run-with-coverage 65 | 66 | # See https://app.codecov.io/github/akm/go-requestid/new 67 | .PHONY: test-with-coverage-unit 68 | test-with-coverage-unit: $(GO_COVERAGE_DIR) 69 | go test -cover ./... -args -test.gocoverdir="$(GO_COVERAGE_DIR)" 70 | 71 | .PHONY: test-coverage-profile 72 | test-coverage-profile: $(GO_COVERAGE_DIR) $(GO_COVERAGE_MERGED_DIR) 73 | go tool covdata merge \ 74 | -i $(GO_COVERAGE_DIR),tests/mysql/coverage/unit,tests/postgres/coverage/unit,tests/sqlite3/coverage/unit \ 75 | -o $(GO_COVERAGE_MERGED_DIR) 76 | go tool covdata percent -i=$(GO_COVERAGE_MERGED_DIR) -o $(GO_COVERAGE_PROFILE) 77 | 78 | .PHONY: test-coverage 79 | test-coverage: test-coverage-profile 80 | go tool cover -html=$(GO_COVERAGE_PROFILE) -o $(GO_COVERAGE_HTML) 81 | @command -v open && open $(GO_COVERAGE_HTML) || echo "open $(GO_COVERAGE_HTML)" 82 | 83 | .PHONY: linters-enabled 84 | linters-enabled: 85 | @golangci-lint linters | awk '/^Enabled by your configuration linters:$$/{flag=1;next}/^Disabled by your configuration linters:$$/{flag=0}flag{print}' 86 | 87 | METADATA_YAML=.project.yaml 88 | $(METADATA_YAML): metadata-gen 89 | 90 | METADATA_LINTERS=$(strip $(shell $(MAKE) linters-enabled --no-print-directory 2>/dev/null | grep . | wc -l)) 91 | .PHONY: metadata-gen 92 | metadata-gen: 93 | @echo "linters: $(METADATA_LINTERS)" > $(METADATA_YAML) 94 | 95 | .PHONY: clean 96 | clean: tests-clean examples-clean 97 | rm -rf coverage 98 | rm -f $(GO_COVERAGE_HTML) $(GO_COVERAGE_PROFILE) 99 | 100 | .PHONY: clobber 101 | clobber: tests-clobber examples-clobber clean 102 | rm -f $(METADATA_YAML) 103 | -------------------------------------------------------------------------------- /examples/logs-mysql/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "os/exec" 10 | "slices" 11 | "time" 12 | 13 | sqlslog "github.com/akm/sql-slog" 14 | _ "github.com/go-sql-driver/mysql" 15 | ) 16 | 17 | func main() { 18 | logLevel := sqlslog.ParseLevelWithDefault(os.Args[1], sqlslog.LevelInfo) 19 | 20 | var handlerFunc func(io.Writer, *slog.HandlerOptions) slog.Handler 21 | if slices.Contains(os.Args, "json") { 22 | handlerFunc = sqlslog.NewJSONHandler 23 | } else { 24 | handlerFunc = sqlslog.NewTextHandler 25 | } 26 | 27 | dbName := "app1" 28 | dbPort := 3306 29 | dsn := fmt.Sprintf("root@tcp(localhost:%d)/%s", dbPort, dbName) 30 | 31 | os.Setenv("MYSQL_PORT", "3306") 32 | os.Setenv("MYSQL_DATABASE", dbName) 33 | if err := exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d").Run(); err != nil { 34 | panic(err) 35 | } 36 | defer func() { 37 | if err := exec.Command("docker", "compose", "-f", "docker-compose.yml", "down").Run(); err != nil { 38 | panic(err) 39 | } 40 | }() 41 | 42 | ctx := context.Background() 43 | 44 | // Open a database 45 | db, logger, err := sqlslog.Open(ctx, "mysql", dsn, 46 | sqlslog.LogLevel(logLevel), 47 | sqlslog.HandlerFunc(handlerFunc), 48 | sqlslog.ConnQueryContext(func(o *sqlslog.StepOptions) { 49 | o.SetLevel(sqlslog.LevelDebug) 50 | }), 51 | ) 52 | if err != nil { 53 | logger.Error("Failed to open database", "error", err) 54 | os.Exit(1) 55 | } 56 | defer db.Close() 57 | 58 | for i := 0; i < 10; i++ { 59 | if err := db.PingContext(ctx); err == nil { 60 | break 61 | } 62 | time.Sleep(2 * time.Second) 63 | } 64 | 65 | // Create a table 66 | query := "CREATE TABLE IF NOT EXISTS test1 (id INT PRIMARY KEY, name VARCHAR(255))" 67 | if _, err := db.ExecContext(ctx, query); err != nil { 68 | logger.Error("Failed to create a table", "error", err) 69 | os.Exit(1) 70 | } 71 | 72 | for i := 0; i < 10; i++ { 73 | if err := db.PingContext(ctx); err == nil { 74 | break 75 | } 76 | time.Sleep(2 * time.Second) 77 | } 78 | 79 | // Insert a record in a transaction 80 | tx, err := db.BeginTx(ctx, nil) 81 | if err != nil { 82 | logger.Error("Failed to begin a transaction", "error", err) 83 | os.Exit(1) 84 | } 85 | 86 | query = "INSERT INTO test1 (id, name) VALUES (?, ?)" 87 | if _, err := tx.ExecContext(ctx, query, 1, "Alice"); err != nil { 88 | logger.Error("Failed to insert a record", "error", err) 89 | if err := tx.Rollback(); err != nil { 90 | logger.Error("Failed to rollback a transaction", "error", err) 91 | } 92 | os.Exit(1) 93 | } 94 | 95 | if err := tx.Commit(); err != nil { 96 | logger.Error("Failed to commit a transaction", "error", err) 97 | os.Exit(1) 98 | } 99 | 100 | // Select records 101 | rows, err := db.QueryContext(ctx, "SELECT * FROM test1") 102 | if err != nil { 103 | logger.Error("Failed to select records", "error", err) 104 | os.Exit(1) 105 | } 106 | defer rows.Close() 107 | 108 | for rows.Next() { 109 | var id int 110 | var name string 111 | if err := rows.Scan(&id, &name); err != nil { 112 | logger.Error("Failed to scan a record", "error", err) 113 | os.Exit(1) 114 | } 115 | logger.InfoContext(ctx, "Record", "id", id, "name", name) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /examples/logs-postgres/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "os/exec" 10 | "slices" 11 | "time" 12 | 13 | sqlslog "github.com/akm/sql-slog" 14 | _ "github.com/lib/pq" 15 | ) 16 | 17 | func main() { 18 | logLevel := sqlslog.ParseLevelWithDefault(os.Args[1], sqlslog.LevelInfo) 19 | 20 | var handlerFunc func(io.Writer, *slog.HandlerOptions) slog.Handler 21 | if slices.Contains(os.Args, "json") { 22 | handlerFunc = sqlslog.NewJSONHandler 23 | } else { 24 | handlerFunc = sqlslog.NewTextHandler 25 | } 26 | dbName := "app1" 27 | dbPort := 5432 28 | dsn := fmt.Sprintf("host=127.0.0.1 port=%d user=root password=password dbname=%s sslmode=disable", dbPort, dbName) 29 | 30 | os.Setenv("POSTGRES_PORT", fmt.Sprintf("%d", dbPort)) 31 | os.Setenv("POSTGRES_DATABASE", dbName) 32 | if err := exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d").Run(); err != nil { 33 | panic(err) 34 | } 35 | defer func() { 36 | if err := exec.Command("docker", "compose", "-f", "docker-compose.yml", "down").Run(); err != nil { 37 | panic(err) 38 | } 39 | }() 40 | 41 | ctx := context.Background() 42 | 43 | // Open a database 44 | db, logger, err := sqlslog.Open(ctx, "postgres", dsn, 45 | sqlslog.LogLevel(logLevel), 46 | sqlslog.HandlerFunc(handlerFunc), 47 | sqlslog.ConnQueryContext(func(o *sqlslog.StepOptions) { 48 | o.SetLevel(sqlslog.LevelDebug) 49 | }), 50 | ) 51 | if err != nil { 52 | logger.Error("Failed to open database", "error", err) 53 | os.Exit(1) 54 | } 55 | defer db.Close() 56 | 57 | for i := 0; i < 10; i++ { 58 | if err := db.PingContext(ctx); err == nil { 59 | break 60 | } 61 | time.Sleep(2 * time.Second) 62 | } 63 | 64 | // Create a table 65 | query := "CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" 66 | if _, err := db.ExecContext(ctx, query); err != nil { 67 | logger.Error("Failed to create a table", "error", err) 68 | os.Exit(1) 69 | } 70 | 71 | for i := 0; i < 10; i++ { 72 | if err := db.PingContext(ctx); err == nil { 73 | break 74 | } 75 | time.Sleep(2 * time.Second) 76 | } 77 | 78 | // Insert a record in a transaction 79 | tx, err := db.BeginTx(ctx, nil) 80 | if err != nil { 81 | logger.Error("Failed to begin a transaction", "error", err) 82 | os.Exit(1) 83 | } 84 | 85 | query = "INSERT INTO test1 (id, name) VALUES ($1,$2);" 86 | if _, err := tx.ExecContext(ctx, query, int64(1), "Alice"); err != nil { 87 | logger.Error("Failed to insert a record", "error", err) 88 | if err := tx.Rollback(); err != nil { 89 | logger.Error("Failed to rollback a transaction", "error", err) 90 | } 91 | os.Exit(1) 92 | } 93 | 94 | if err := tx.Commit(); err != nil { 95 | logger.Error("Failed to commit a transaction", "error", err) 96 | os.Exit(1) 97 | } 98 | 99 | // Select records 100 | rows, err := db.QueryContext(ctx, "SELECT * FROM test1") 101 | if err != nil { 102 | logger.Error("Failed to select records", "error", err) 103 | os.Exit(1) 104 | } 105 | defer rows.Close() 106 | 107 | for rows.Next() { 108 | var id int 109 | var name string 110 | if err := rows.Scan(&id, &name); err != nil { 111 | logger.Error("Failed to scan a record", "error", err) 112 | os.Exit(1) 113 | } 114 | logger.InfoContext(ctx, "Record", "id", id, "name", name) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tests/sqlite3/duration_option_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | sqlslog "github.com/akm/sql-slog" 11 | "github.com/akm/sql-slog/tests/testhelper" 12 | _ "github.com/mattn/go-sqlite3" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestDuration(t *testing.T) { 18 | dsn := "./sqlite3_test.db" 19 | defer os.Remove(dsn) 20 | 21 | ctx := context.TODO() 22 | 23 | type testCase struct { 24 | durationKey string 25 | durationType sqlslog.DurationType 26 | assertion func(t *testing.T, logs *testhelper.LogsAssertion) 27 | } 28 | 29 | findCompleteLog := func(t *testing.T, logs *testhelper.LogsAssertion) map[string]interface{} { 30 | for _, line := range logs.JsonLines(t) { 31 | if msg, ok := line["msg"].(string); ok && strings.Contains(msg, " Complete") { 32 | return line 33 | } 34 | } 35 | return nil 36 | } 37 | assertFloat64Duration := func(key string) func(t *testing.T, log map[string]interface{}) { 38 | return func(t *testing.T, log map[string]interface{}) { 39 | require.NotNil(t, log) 40 | require.NotNil(t, log[key]) 41 | assert.IsType(t, float64(0), log[key]) // Value must be a float64 because of JSON unmarshaling 42 | assert.GreaterOrEqual(t, log[key], float64(0)) 43 | } 44 | } 45 | 46 | testCases := []testCase{ 47 | { 48 | durationKey: "d", 49 | durationType: sqlslog.DurationNanoSeconds, 50 | assertion: func(t *testing.T, logs *testhelper.LogsAssertion) { 51 | assertFloat64Duration("d")(t, findCompleteLog(t, logs)) 52 | }, 53 | }, 54 | { 55 | durationKey: "duration-μs", 56 | durationType: sqlslog.DurationMicroSeconds, 57 | assertion: func(t *testing.T, logs *testhelper.LogsAssertion) { 58 | assertFloat64Duration("duration-μs")(t, findCompleteLog(t, logs)) 59 | }, 60 | }, 61 | { 62 | durationKey: "duration-msec", 63 | durationType: sqlslog.DurationMilliSeconds, 64 | assertion: func(t *testing.T, logs *testhelper.LogsAssertion) { 65 | assertFloat64Duration("duration-msec")(t, findCompleteLog(t, logs)) 66 | }, 67 | }, 68 | { 69 | durationKey: "duration-of-go", 70 | durationType: sqlslog.DurationGoDuration, 71 | assertion: func(t *testing.T, logs *testhelper.LogsAssertion) { 72 | assertFloat64Duration("duration-of-go")(t, findCompleteLog(t, logs)) 73 | }, 74 | }, 75 | { 76 | durationKey: "duration-string", 77 | durationType: sqlslog.DurationString, 78 | assertion: func(t *testing.T, logs *testhelper.LogsAssertion) { 79 | log := findCompleteLog(t, logs) 80 | require.NotNil(t, log) 81 | assert.Regexp(t, `^[\d.]+(s|ms|µs|ns)$`, log["duration-string"]) 82 | }, 83 | }, 84 | } 85 | 86 | for _, tc := range testCases { 87 | t.Run(tc.durationKey, func(t *testing.T) { 88 | buf := bytes.NewBuffer(nil) 89 | logs := testhelper.NewLogAssertion(buf) 90 | db, _, err := sqlslog.Open(ctx, "sqlite3", dsn, 91 | append( 92 | testhelper.StepEventMsgOptions, 93 | sqlslog.HandlerFunc(sqlslog.NewJSONHandler), 94 | sqlslog.LogWriter(buf), 95 | sqlslog.LogLevel(sqlslog.LevelVerbose), 96 | sqlslog.DurationKey(tc.durationKey), 97 | sqlslog.Duration(tc.durationType), 98 | )..., 99 | ) 100 | require.NoError(t, err) 101 | defer db.Close() 102 | 103 | tc.assertion(t, logs) 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /slog_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "bytes" 5 | "log/slog" 6 | "testing" 7 | ) 8 | 9 | func TestNewTextHandler(t *testing.T) { 10 | t.Parallel() 11 | buf := bytes.NewBuffer(nil) 12 | h := NewTextHandler(buf, nil) 13 | if h == nil { 14 | t.Errorf("want not nil, got nil") 15 | } 16 | } 17 | 18 | func TestWrapHandlerOptions(t *testing.T) { 19 | t.Parallel() 20 | t.Run("nil parameter", func(t *testing.T) { 21 | t.Parallel() 22 | h := WrapHandlerOptions(nil) 23 | if h == nil { 24 | t.Errorf("want not nil, got nil") 25 | } 26 | }) 27 | t.Run("one parameter", func(t *testing.T) { 28 | t.Parallel() 29 | h := WrapHandlerOptions(&slog.HandlerOptions{}) 30 | if h == nil { 31 | t.Errorf("want not nil, got nil") 32 | } else if h.ReplaceAttr == nil { 33 | t.Errorf("want not nil, got nil") 34 | } 35 | }) 36 | } 37 | 38 | func TestMergeReplaceAttrs(t *testing.T) { 39 | t.Parallel() 40 | t.Run("no parameter", func(t *testing.T) { 41 | t.Parallel() 42 | f := MergeReplaceAttrs() 43 | if f != nil { 44 | t.Error("want nil, got not nil function") 45 | } 46 | }) 47 | t.Run("nil parameter", func(t *testing.T) { 48 | t.Parallel() 49 | f := MergeReplaceAttrs(nil, nil) 50 | if f != nil { 51 | t.Error("want nil, got not nil function") 52 | } 53 | }) 54 | t.Run("one parameter", func(t *testing.T) { 55 | t.Parallel() 56 | called := false 57 | f := MergeReplaceAttrs(func(_ []string, a slog.Attr) slog.Attr { 58 | called = true 59 | return a 60 | }) 61 | if f == nil { 62 | t.Errorf("want not nil, got nil") 63 | } 64 | r := f([]string{}, slog.String("key", "value")) 65 | if !called { 66 | t.Errorf("want called, got not called") 67 | } 68 | if r.Key != "key" { 69 | t.Errorf("want key as r.Key, got %s", r.Key) 70 | } 71 | if r.Value.Kind() != slog.KindString || r.Value.String() != "value" { 72 | t.Errorf("want value as r.Value, got %v", r.Value) 73 | } 74 | }) 75 | t.Run("two parameters", func(t *testing.T) { 76 | t.Parallel() 77 | f := MergeReplaceAttrs( 78 | func(_ []string, a slog.Attr) slog.Attr { 79 | return slog.String(a.Key, a.Value.String()+"+1") 80 | }, 81 | func(_ []string, a slog.Attr) slog.Attr { 82 | return slog.String(a.Key, a.Value.String()+"+2") 83 | }, 84 | ) 85 | if f == nil { 86 | t.Errorf("want not nil, got nil") 87 | } 88 | r := f([]string{}, slog.String("key", "value")) 89 | if r.Key != "key" { 90 | t.Errorf("want key as r.Key, got %s", r.Key) 91 | } 92 | if r.Value.Kind() != slog.KindString || r.Value.String() != "value+1+2" { 93 | t.Errorf("want value as r.Value, got %v", r.Value) 94 | } 95 | }) 96 | } 97 | 98 | func TestHandlerOptions(t *testing.T) { 99 | t.Parallel() 100 | t.Run("nil parameter", func(t *testing.T) { 101 | t.Parallel() 102 | o := HandlerOptions(nil) 103 | if o == nil { 104 | t.Errorf("want not nil, got nil") 105 | } 106 | opts := &options{SlogOptions: defaultSlogOptions()} 107 | o(opts) 108 | }) 109 | t.Run("one parameter", func(t *testing.T) { 110 | t.Parallel() 111 | o := HandlerOptions(&slog.HandlerOptions{}) 112 | if o == nil { 113 | t.Errorf("want not nil, got nil") 114 | } 115 | opts := &options{SlogOptions: defaultSlogOptions()} 116 | o(opts) 117 | }) 118 | } 119 | 120 | func TestAddSource(t *testing.T) { 121 | t.Parallel() 122 | o := AddSource(true) 123 | if o == nil { 124 | t.Errorf("want not nil, got nil") 125 | } 126 | opts := &options{SlogOptions: defaultSlogOptions()} 127 | o(opts) 128 | } 129 | -------------------------------------------------------------------------------- /stmt_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "database/sql/driver" 7 | "errors" 8 | "log/slog" 9 | "testing" 10 | ) 11 | 12 | type mockStmtForWrapStmt struct { 13 | error error 14 | } 15 | 16 | var _ driver.Stmt = (*mockStmtForWrapStmt)(nil) 17 | 18 | // Close implements driver.Stmt. 19 | func (m *mockStmtForWrapStmt) Close() error { 20 | return m.error 21 | } 22 | 23 | // Exec implements driver.Stmt. 24 | func (m *mockStmtForWrapStmt) Exec([]driver.Value) (driver.Result, error) { 25 | return nil, m.error 26 | } 27 | 28 | // NumInput implements driver.Stmt. 29 | func (m *mockStmtForWrapStmt) NumInput() int { 30 | panic("unimplemented") 31 | } 32 | 33 | // Query implements driver.Stmt. 34 | func (m *mockStmtForWrapStmt) Query([]driver.Value) (driver.Rows, error) { 35 | return nil, m.error 36 | } 37 | 38 | func TestWrapStmt(t *testing.T) { 39 | t.Parallel() 40 | t.Run("nil", func(t *testing.T) { 41 | t.Parallel() 42 | if wrapStmt(nil, nil, nil) != nil { 43 | t.Fatal("Expected nil") 44 | } 45 | }) 46 | t.Run("implements driver.Stmt but not stmtWithContext", func(t *testing.T) { 47 | t.Parallel() 48 | mock := &mockStmtForWrapStmt{} 49 | logger := &stepLogger{} 50 | stmt := wrapStmt(mock, logger, defaultStmtOptions(StepEventMsgWithoutEventName)) 51 | if stmt == nil { 52 | t.Fatal("Expected non-nil") 53 | } 54 | }) 55 | 56 | t.Run("Query", func(t *testing.T) { 57 | t.Parallel() 58 | dummyError := errors.New("unexpected Query error") 59 | mock := &mockStmtForWrapStmt{ 60 | error: dummyError, 61 | } 62 | 63 | buf := bytes.NewBuffer(nil) 64 | logger := slog.New(NewJSONHandler(buf, nil)) 65 | wrapped := wrapStmt(mock, newStepLogger(logger, defaultStepLoggerOptions()), defaultStmtOptions(StepEventMsgWithoutEventName)) 66 | _, err := wrapped.Query(nil) // nolint:staticcheck 67 | if err == nil { 68 | t.Fatal("Expected non-nil") 69 | } 70 | if !errors.Is(err, dummyError) { 71 | t.Fatalf("Expected %q but got %q", dummyError, err) 72 | } 73 | }) 74 | } 75 | 76 | type mockErrorStmtWithContext struct { 77 | mockStmtForWrapStmt 78 | error error 79 | } 80 | 81 | var ( 82 | _ driver.Stmt = (*mockErrorStmtWithContext)(nil) 83 | _ driver.StmtExecContext = (*mockErrorStmtWithContext)(nil) 84 | _ driver.StmtQueryContext = (*mockErrorStmtWithContext)(nil) 85 | ) 86 | 87 | func (m *mockErrorStmtWithContext) QueryContext(context.Context, []driver.NamedValue) (driver.Rows, error) { 88 | return nil, m.error 89 | } 90 | 91 | func (m *mockErrorStmtWithContext) ExecContext(context.Context, []driver.NamedValue) (driver.Result, error) { 92 | return nil, m.error 93 | } 94 | 95 | func TestWithMockErrorStmtWithContext(t *testing.T) { 96 | t.Parallel() 97 | dummyError := errors.New("unexpected QueryContext error") 98 | mock := &mockErrorStmtWithContext{ 99 | mockStmtForWrapStmt: mockStmtForWrapStmt{}, 100 | error: dummyError, 101 | } 102 | 103 | buf := bytes.NewBuffer(nil) 104 | logger := slog.New(NewJSONHandler(buf, nil)) 105 | wrapped := wrapStmt(mock, newStepLogger(logger, defaultStepLoggerOptions()), defaultStmtOptions(StepEventMsgWithoutEventName)) 106 | stmtWithQueryContext, ok := wrapped.(driver.StmtQueryContext) 107 | if !ok { 108 | t.Fatal("Expected StmtQueryContext") 109 | } 110 | _, err := stmtWithQueryContext.QueryContext(context.TODO(), nil) 111 | if err == nil { 112 | t.Fatal("Expected non-nil") 113 | } 114 | if !errors.Is(err, dummyError) { 115 | t.Fatalf("Expected %q but got %q", dummyError, err) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /examples/logs-sqlite3/results/verbose-log.json: -------------------------------------------------------------------------------- 1 | {"time":"2025-08-06T23:23:52.116283+09:00","level":"TRACE","msg":"Open","driver":"sqlite3","dsn":"file::memory:?cache=shared"} 2 | {"time":"2025-08-06T23:23:52.116387+09:00","level":"INFO","msg":"Open","driver":"sqlite3","dsn":"file::memory:?cache=shared","duration":9500} 3 | {"time":"2025-08-06T23:23:52.116395+09:00","level":"TRACE","msg":"Connector.Connect"} 4 | {"time":"2025-08-06T23:23:52.116399+09:00","level":"TRACE","msg":"Driver.Open","dsn":"file::memory:?cache=shared"} 5 | {"time":"2025-08-06T23:23:52.116534+09:00","level":"INFO","msg":"Driver.Open","dsn":"file::memory:?cache=shared","duration":130542,"conn_id":"SJ9moTag3YK8MQLF"} 6 | {"time":"2025-08-06T23:23:52.116544+09:00","level":"INFO","msg":"Connector.Connect","duration":144625} 7 | {"time":"2025-08-06T23:23:52.11655+09:00","level":"TRACE","msg":"Conn.ExecContext","conn_id":"SJ9moTag3YK8MQLF","query":"CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))","args":"[]"} 8 | {"time":"2025-08-06T23:23:52.116595+09:00","level":"INFO","msg":"Conn.ExecContext","conn_id":"SJ9moTag3YK8MQLF","query":"CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))","args":"[]","duration":40292} 9 | {"time":"2025-08-06T23:23:52.1166+09:00","level":"VERBOSE","msg":"Conn.ResetSession","conn_id":"SJ9moTag3YK8MQLF"} 10 | {"time":"2025-08-06T23:23:52.116603+09:00","level":"TRACE","msg":"Conn.ResetSession","conn_id":"SJ9moTag3YK8MQLF","duration":334} 11 | {"time":"2025-08-06T23:23:52.116606+09:00","level":"TRACE","msg":"Conn.BeginTx","conn_id":"SJ9moTag3YK8MQLF"} 12 | {"time":"2025-08-06T23:23:52.116614+09:00","level":"INFO","msg":"Conn.BeginTx","conn_id":"SJ9moTag3YK8MQLF","duration":5625,"tx_id":"eDDVDXHsbgT0oPqc"} 13 | {"time":"2025-08-06T23:23:52.116624+09:00","level":"TRACE","msg":"Conn.ExecContext","conn_id":"SJ9moTag3YK8MQLF","query":"INSERT INTO test1 (name) VALUES (?)","args":"[\"Alice\"]"} 14 | {"time":"2025-08-06T23:23:52.116639+09:00","level":"INFO","msg":"Conn.ExecContext","conn_id":"SJ9moTag3YK8MQLF","query":"INSERT INTO test1 (name) VALUES (?)","args":"[\"Alice\"]","duration":8958} 15 | {"time":"2025-08-06T23:23:52.116646+09:00","level":"TRACE","msg":"Tx.Commit","conn_id":"SJ9moTag3YK8MQLF","tx_id":"eDDVDXHsbgT0oPqc"} 16 | {"time":"2025-08-06T23:23:52.116651+09:00","level":"INFO","msg":"Tx.Commit","conn_id":"SJ9moTag3YK8MQLF","tx_id":"eDDVDXHsbgT0oPqc","duration":3167} 17 | {"time":"2025-08-06T23:23:52.116655+09:00","level":"VERBOSE","msg":"Conn.ResetSession","conn_id":"SJ9moTag3YK8MQLF"} 18 | {"time":"2025-08-06T23:23:52.116657+09:00","level":"TRACE","msg":"Conn.ResetSession","conn_id":"SJ9moTag3YK8MQLF","duration":0} 19 | {"time":"2025-08-06T23:23:52.11666+09:00","level":"TRACE","msg":"Conn.QueryContext","conn_id":"SJ9moTag3YK8MQLF","query":"SELECT * FROM test1","args":"[]"} 20 | {"time":"2025-08-06T23:23:52.116667+09:00","level":"DEBUG","msg":"Conn.QueryContext","conn_id":"SJ9moTag3YK8MQLF","query":"SELECT * FROM test1","args":"[]","duration":4292} 21 | {"time":"2025-08-06T23:23:52.116672+09:00","level":"VERBOSE","msg":"Rows.Next","conn_id":"SJ9moTag3YK8MQLF"} 22 | {"time":"2025-08-06T23:23:52.116678+09:00","level":"DEBUG","msg":"Rows.Next","conn_id":"SJ9moTag3YK8MQLF","duration":2833,"eof":false} 23 | {"time":"2025-08-06T23:23:52.116686+09:00","level":"INFO","msg":"Record","id":1,"name":"Alice"} 24 | {"time":"2025-08-06T23:23:52.116688+09:00","level":"VERBOSE","msg":"Rows.Next","conn_id":"SJ9moTag3YK8MQLF"} 25 | {"time":"2025-08-06T23:23:52.116692+09:00","level":"DEBUG","msg":"Rows.Next","conn_id":"SJ9moTag3YK8MQLF","duration":916,"eof":true} 26 | {"time":"2025-08-06T23:23:52.116695+09:00","level":"VERBOSE","msg":"Rows.Close","conn_id":"SJ9moTag3YK8MQLF"} 27 | {"time":"2025-08-06T23:23:52.116698+09:00","level":"DEBUG","msg":"Rows.Close","conn_id":"SJ9moTag3YK8MQLF","duration":458} 28 | {"time":"2025-08-06T23:23:52.116702+09:00","level":"TRACE","msg":"Conn.Close","conn_id":"SJ9moTag3YK8MQLF"} 29 | {"time":"2025-08-06T23:23:52.116715+09:00","level":"INFO","msg":"Conn.Close","conn_id":"SJ9moTag3YK8MQLF","duration":10333} 30 | -------------------------------------------------------------------------------- /examples/logs-postgres/results/trace-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:23:58.972+09:00 level=TRACE msg=Open driver=postgres dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" 2 | time=2025-08-06T23:23:58.973+09:00 level=INFO msg=Open driver=postgres dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=14875 3 | time=2025-08-06T23:23:58.973+09:00 level=TRACE msg=Connector.Connect 4 | time=2025-08-06T23:23:58.973+09:00 level=TRACE msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" 5 | time=2025-08-06T23:23:58.973+09:00 level=ERROR msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=546958 error="read tcp 127.0.0.1:61639->127.0.0.1:5432: read: connection reset by peer" 6 | time=2025-08-06T23:23:58.973+09:00 level=ERROR msg=Connector.Connect duration=570666 error="read tcp 127.0.0.1:61639->127.0.0.1:5432: read: connection reset by peer" 7 | time=2025-08-06T23:24:00.974+09:00 level=TRACE msg=Connector.Connect 8 | time=2025-08-06T23:24:00.975+09:00 level=TRACE msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" 9 | time=2025-08-06T23:24:00.992+09:00 level=INFO msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=17226250 conn_id=brVzLT_MX4DoRbYL 10 | time=2025-08-06T23:24:00.992+09:00 level=INFO msg=Connector.Connect duration=17481583 11 | time=2025-08-06T23:24:00.993+09:00 level=TRACE msg=Conn.Ping conn_id=brVzLT_MX4DoRbYL duration=615167 12 | time=2025-08-06T23:24:00.993+09:00 level=TRACE msg=Conn.ResetSession conn_id=brVzLT_MX4DoRbYL duration=1250 13 | time=2025-08-06T23:24:00.993+09:00 level=TRACE msg=Conn.ExecContext conn_id=brVzLT_MX4DoRbYL query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] 14 | time=2025-08-06T23:24:00.997+09:00 level=INFO msg=Conn.ExecContext conn_id=brVzLT_MX4DoRbYL query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] duration=4647250 15 | time=2025-08-06T23:24:00.998+09:00 level=TRACE msg=Conn.ResetSession conn_id=brVzLT_MX4DoRbYL duration=708 16 | time=2025-08-06T23:24:00.998+09:00 level=TRACE msg=Conn.Ping conn_id=brVzLT_MX4DoRbYL duration=313209 17 | time=2025-08-06T23:24:00.998+09:00 level=TRACE msg=Conn.ResetSession conn_id=brVzLT_MX4DoRbYL duration=166 18 | time=2025-08-06T23:24:00.998+09:00 level=TRACE msg=Conn.BeginTx conn_id=brVzLT_MX4DoRbYL 19 | time=2025-08-06T23:24:00.998+09:00 level=INFO msg=Conn.BeginTx conn_id=brVzLT_MX4DoRbYL duration=302167 tx_id=VyFX1vwTC50lUJKd 20 | time=2025-08-06T23:24:00.998+09:00 level=TRACE msg=Conn.ExecContext conn_id=brVzLT_MX4DoRbYL query="INSERT INTO test1 (id, name) VALUES ($1,$2);" args="[1,\"Alice\"]" 21 | time=2025-08-06T23:24:01.000+09:00 level=INFO msg=Conn.ExecContext conn_id=brVzLT_MX4DoRbYL query="INSERT INTO test1 (id, name) VALUES ($1,$2);" args="[1,\"Alice\"]" duration=1190209 22 | time=2025-08-06T23:24:01.000+09:00 level=TRACE msg=Tx.Commit conn_id=brVzLT_MX4DoRbYL tx_id=VyFX1vwTC50lUJKd 23 | time=2025-08-06T23:24:01.001+09:00 level=INFO msg=Tx.Commit conn_id=brVzLT_MX4DoRbYL tx_id=VyFX1vwTC50lUJKd duration=1004709 24 | time=2025-08-06T23:24:01.001+09:00 level=TRACE msg=Conn.ResetSession conn_id=brVzLT_MX4DoRbYL duration=459 25 | time=2025-08-06T23:24:01.001+09:00 level=TRACE msg=Conn.QueryContext conn_id=brVzLT_MX4DoRbYL query="SELECT * FROM test1" args=[] 26 | time=2025-08-06T23:24:01.001+09:00 level=DEBUG msg=Conn.QueryContext conn_id=brVzLT_MX4DoRbYL query="SELECT * FROM test1" args=[] duration=715958 27 | time=2025-08-06T23:24:01.002+09:00 level=DEBUG msg=Rows.Next conn_id=brVzLT_MX4DoRbYL duration=3625 eof=false 28 | time=2025-08-06T23:24:01.002+09:00 level=INFO msg=Record id=1 name=Alice 29 | time=2025-08-06T23:24:01.002+09:00 level=DEBUG msg=Rows.Next conn_id=brVzLT_MX4DoRbYL duration=2417 eof=true 30 | time=2025-08-06T23:24:01.002+09:00 level=DEBUG msg=Rows.Close conn_id=brVzLT_MX4DoRbYL duration=584 31 | time=2025-08-06T23:24:01.002+09:00 level=TRACE msg=Conn.Close conn_id=brVzLT_MX4DoRbYL 32 | time=2025-08-06T23:24:01.002+09:00 level=INFO msg=Conn.Close conn_id=brVzLT_MX4DoRbYL duration=56250 33 | -------------------------------------------------------------------------------- /examples/logs-mysql/results/debug-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:24:18.087+09:00 level=INFO msg=Driver.OpenConnector dsn=root@tcp(localhost:3306)/app1 duration=10917 conn_id=OeV2RQ9XRdMCt45H 2 | time=2025-08-06T23:24:18.087+09:00 level=INFO msg=Open driver=mysql dsn=root@tcp(localhost:3306)/app1 duration=470875 3 | time=2025-08-06T23:24:18.089+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=2305125 error="driver: bad connection" 4 | time=2025-08-06T23:24:18.089+09:00 level=ERROR msg=Connector.Connect duration=2338958 error="driver: bad connection" 5 | time=2025-08-06T23:24:18.090+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=714542 error="driver: bad connection" 6 | time=2025-08-06T23:24:18.090+09:00 level=ERROR msg=Connector.Connect duration=770750 error="driver: bad connection" 7 | time=2025-08-06T23:24:18.091+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=683833 error="driver: bad connection" 8 | time=2025-08-06T23:24:18.091+09:00 level=ERROR msg=Connector.Connect duration=693000 error="driver: bad connection" 9 | time=2025-08-06T23:24:20.093+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=1196917 error="driver: bad connection" 10 | time=2025-08-06T23:24:20.093+09:00 level=ERROR msg=Connector.Connect duration=1244834 error="driver: bad connection" 11 | time=2025-08-06T23:24:20.094+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=1030584 error="driver: bad connection" 12 | time=2025-08-06T23:24:20.094+09:00 level=ERROR msg=Connector.Connect duration=1042042 error="driver: bad connection" 13 | time=2025-08-06T23:24:20.096+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=1188083 error="driver: bad connection" 14 | time=2025-08-06T23:24:20.096+09:00 level=ERROR msg=Connector.Connect duration=1195750 error="driver: bad connection" 15 | time=2025-08-06T23:24:22.097+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=843625 error="driver: bad connection" 16 | time=2025-08-06T23:24:22.097+09:00 level=ERROR msg=Connector.Connect duration=874750 error="driver: bad connection" 17 | time=2025-08-06T23:24:22.097+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=464875 error="driver: bad connection" 18 | time=2025-08-06T23:24:22.097+09:00 level=ERROR msg=Connector.Connect duration=475041 error="driver: bad connection" 19 | time=2025-08-06T23:24:22.097+09:00 level=ERROR msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=337459 error="driver: bad connection" 20 | time=2025-08-06T23:24:22.097+09:00 level=ERROR msg=Connector.Connect duration=346666 error="driver: bad connection" 21 | time=2025-08-06T23:24:24.103+09:00 level=INFO msg=Connector.Connect conn_id=OeV2RQ9XRdMCt45H duration=4660583 22 | time=2025-08-06T23:24:24.103+09:00 level=INFO msg=Connector.Connect duration=4740458 23 | time=2025-08-06T23:24:24.122+09:00 level=INFO msg=Conn.ExecContext conn_id=OeV2RQ9XRdMCt45H query="CREATE TABLE IF NOT EXISTS test1 (id INT PRIMARY KEY, name VARCHAR(255))" args=[] duration=17941958 24 | time=2025-08-06T23:24:24.122+09:00 level=INFO msg=Conn.BeginTx conn_id=OeV2RQ9XRdMCt45H duration=244000 tx_id=2Z_gwjGK0ZsOhmKL 25 | time=2025-08-06T23:24:24.122+09:00 level=INFO msg=Conn.ExecContext conn_id=OeV2RQ9XRdMCt45H query="INSERT INTO test1 (id, name) VALUES (?, ?)" args="[1,\"Alice\"]" duration=541 skip=true 26 | time=2025-08-06T23:24:24.127+09:00 level=DEBUG msg=Stmt.ExecContext conn_id=OeV2RQ9XRdMCt45H stmt_id=Sh0A2dDAax1M8Fuj args="[1,\"Alice\"]" duration=610916 27 | time=2025-08-06T23:24:24.127+09:00 level=DEBUG msg=Stmt.Close conn_id=OeV2RQ9XRdMCt45H stmt_id=Sh0A2dDAax1M8Fuj duration=7917 28 | time=2025-08-06T23:24:24.128+09:00 level=INFO msg=Tx.Commit conn_id=OeV2RQ9XRdMCt45H tx_id=2Z_gwjGK0ZsOhmKL duration=1560625 29 | time=2025-08-06T23:24:24.129+09:00 level=DEBUG msg=Conn.QueryContext conn_id=OeV2RQ9XRdMCt45H query="SELECT * FROM test1" args=[] duration=299875 30 | time=2025-08-06T23:24:24.129+09:00 level=DEBUG msg=Rows.Next conn_id=OeV2RQ9XRdMCt45H duration=1458 eof=false 31 | time=2025-08-06T23:24:24.129+09:00 level=INFO msg=Record id=1 name=Alice 32 | time=2025-08-06T23:24:24.129+09:00 level=DEBUG msg=Rows.Next conn_id=OeV2RQ9XRdMCt45H duration=42 eof=true 33 | time=2025-08-06T23:24:24.129+09:00 level=DEBUG msg=Rows.Close conn_id=OeV2RQ9XRdMCt45H duration=167 34 | time=2025-08-06T23:24:24.129+09:00 level=INFO msg=Conn.Close conn_id=OeV2RQ9XRdMCt45H duration=20500 35 | -------------------------------------------------------------------------------- /examples/logs-mysql/results/info-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:24:08.002+09:00 level=INFO msg=Driver.OpenConnector dsn=root@tcp(localhost:3306)/app1 duration=20000 conn_id=DfU15vIYYze23D37 2 | time=2025-08-06T23:24:08.002+09:00 level=INFO msg=Open driver=mysql dsn=root@tcp(localhost:3306)/app1 duration=1456250 3 | time=2025-08-06T23:24:08.005+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=3119500 error="driver: bad connection" 4 | time=2025-08-06T23:24:08.005+09:00 level=ERROR msg=Connector.Connect duration=3177541 error="driver: bad connection" 5 | time=2025-08-06T23:24:08.006+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=700125 error="driver: bad connection" 6 | time=2025-08-06T23:24:08.006+09:00 level=ERROR msg=Connector.Connect duration=746833 error="driver: bad connection" 7 | time=2025-08-06T23:24:08.006+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=515250 error="driver: bad connection" 8 | time=2025-08-06T23:24:08.006+09:00 level=ERROR msg=Connector.Connect duration=556833 error="driver: bad connection" 9 | time=2025-08-06T23:24:10.009+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=1850209 error="driver: bad connection" 10 | time=2025-08-06T23:24:10.009+09:00 level=ERROR msg=Connector.Connect duration=1957167 error="driver: bad connection" 11 | time=2025-08-06T23:24:10.010+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=1011375 error="driver: bad connection" 12 | time=2025-08-06T23:24:10.010+09:00 level=ERROR msg=Connector.Connect duration=1046458 error="driver: bad connection" 13 | time=2025-08-06T23:24:10.011+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=605875 error="driver: bad connection" 14 | time=2025-08-06T23:24:10.011+09:00 level=ERROR msg=Connector.Connect duration=615958 error="driver: bad connection" 15 | time=2025-08-06T23:24:12.013+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=1167709 error="driver: bad connection" 16 | time=2025-08-06T23:24:12.013+09:00 level=ERROR msg=Connector.Connect duration=1237792 error="driver: bad connection" 17 | time=2025-08-06T23:24:12.014+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=778959 error="driver: bad connection" 18 | time=2025-08-06T23:24:12.014+09:00 level=ERROR msg=Connector.Connect duration=828834 error="driver: bad connection" 19 | time=2025-08-06T23:24:12.014+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=622583 error="driver: bad connection" 20 | time=2025-08-06T23:24:12.014+09:00 level=ERROR msg=Connector.Connect duration=686917 error="driver: bad connection" 21 | time=2025-08-06T23:24:14.020+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=4834000 error="driver: bad connection" 22 | time=2025-08-06T23:24:14.021+09:00 level=ERROR msg=Connector.Connect duration=5222417 error="driver: bad connection" 23 | time=2025-08-06T23:24:14.023+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=1818292 error="driver: bad connection" 24 | time=2025-08-06T23:24:14.023+09:00 level=ERROR msg=Connector.Connect duration=1909917 error="driver: bad connection" 25 | time=2025-08-06T23:24:14.024+09:00 level=ERROR msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=1358083 error="driver: bad connection" 26 | time=2025-08-06T23:24:14.024+09:00 level=ERROR msg=Connector.Connect duration=1482167 error="driver: bad connection" 27 | time=2025-08-06T23:24:16.028+09:00 level=INFO msg=Connector.Connect conn_id=DfU15vIYYze23D37 duration=2883875 28 | time=2025-08-06T23:24:16.028+09:00 level=INFO msg=Connector.Connect duration=2958417 29 | time=2025-08-06T23:24:16.043+09:00 level=INFO msg=Conn.ExecContext conn_id=DfU15vIYYze23D37 query="CREATE TABLE IF NOT EXISTS test1 (id INT PRIMARY KEY, name VARCHAR(255))" args=[] duration=14629041 30 | time=2025-08-06T23:24:16.043+09:00 level=INFO msg=Conn.BeginTx conn_id=DfU15vIYYze23D37 duration=367084 tx_id=NxCr1kqGEs0dOBQz 31 | time=2025-08-06T23:24:16.043+09:00 level=INFO msg=Conn.ExecContext conn_id=DfU15vIYYze23D37 query="INSERT INTO test1 (id, name) VALUES (?, ?)" args="[1,\"Alice\"]" duration=500 skip=true 32 | time=2025-08-06T23:24:16.049+09:00 level=INFO msg=Tx.Commit conn_id=DfU15vIYYze23D37 tx_id=NxCr1kqGEs0dOBQz duration=1386458 33 | time=2025-08-06T23:24:16.049+09:00 level=INFO msg=Record id=1 name=Alice 34 | time=2025-08-06T23:24:16.049+09:00 level=INFO msg=Conn.Close conn_id=DfU15vIYYze23D37 duration=27667 35 | -------------------------------------------------------------------------------- /driver.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "database/sql/driver" 5 | "log/slog" 6 | "strings" 7 | ) 8 | 9 | type driverOptions struct { 10 | IDGen IDGen 11 | ConnIDKey string 12 | 13 | Open StepOptions 14 | OpenConnector StepOptions 15 | 16 | ConnOptions *connOptions 17 | ConnectorOptions *connectorOptions 18 | } 19 | 20 | func defaultDriverOptions(driverName string, msgb StepEventMsgBuilder) *driverOptions { 21 | connectorOptions := defaultConnectorOptions(driverName, msgb) 22 | connOptions := connectorOptions.ConnOptions 23 | return &driverOptions{ 24 | IDGen: IDGeneratorDefault, 25 | ConnIDKey: "conn_id", 26 | 27 | Open: *defaultStepOptions(msgb, StepDriverOpen, LevelInfo), 28 | OpenConnector: *defaultStepOptions(msgb, StepDriverOpenConnector, LevelInfo), 29 | 30 | ConnOptions: connOptions, 31 | ConnectorOptions: connectorOptions, 32 | } 33 | } 34 | 35 | func wrapDriver(original driver.Driver, logger *stepLogger, options *driverOptions) driver.Driver { 36 | driverWrapper := driverWrapper{ 37 | original: original, 38 | logger: logger, 39 | options: options, 40 | } 41 | if dc, ok := original.(driver.DriverContext); ok { 42 | return &driverContextWrapper{ 43 | driverWrapper: driverWrapper, 44 | original: dc, 45 | } 46 | } 47 | return &driverWrapper 48 | } 49 | 50 | // https://pkg.go.dev/database/sql/driver@go1.23.4#pkg-overview 51 | // The driver interface has evolved over time. Drivers 52 | // should implement Connector and DriverContext interfaces. 53 | type driverWrapper struct { 54 | original driver.Driver 55 | logger *stepLogger 56 | options *driverOptions 57 | } 58 | 59 | var _ driver.Driver = (*driverWrapper)(nil) 60 | 61 | // Open implements driver.Driver. 62 | func (w *driverWrapper) Open(dsn string) (driver.Conn, error) { 63 | var origConn driver.Conn 64 | attr, err := w.logger.With(slog.String("dsn", dsn)).StepWithoutContext(&w.options.Open, func() (*slog.Attr, error) { 65 | var err error 66 | origConn, err = w.original.Open(dsn) 67 | if err != nil { 68 | return nil, err 69 | } 70 | attrRaw := slog.String(w.options.ConnIDKey, w.options.IDGen()) 71 | return &attrRaw, err 72 | }) 73 | if err != nil { 74 | return nil, err 75 | } 76 | lg := w.logger 77 | if attr != nil { 78 | lg = lg.With(*attr) 79 | } 80 | 81 | return wrapConn(origConn, lg, w.options.ConnOptions), nil 82 | } 83 | 84 | type driverContextWrapper struct { 85 | driverWrapper 86 | original driver.DriverContext 87 | } 88 | 89 | var ( 90 | _ driver.Driver = (*driverContextWrapper)(nil) 91 | _ driver.DriverContext = (*driverContextWrapper)(nil) 92 | ) 93 | 94 | // var _ driver.Connector = (*driverWrapper)(nil) 95 | 96 | // OpenConnector implements driver.DriverContext. 97 | func (w *driverContextWrapper) OpenConnector(dsn string) (driver.Connector, error) { // nolint:funlen 98 | var origConnector driver.Connector 99 | attr, err := w.logger.With(slog.String("dsn", dsn)).StepWithoutContext(&w.options.OpenConnector, func() (*slog.Attr, error) { 100 | var err error 101 | origConnector, err = w.original.OpenConnector(dsn) 102 | if err != nil { 103 | return nil, err 104 | } 105 | attrRaw := slog.String(w.options.ConnIDKey, w.options.IDGen()) 106 | return &attrRaw, err 107 | }) 108 | if err != nil { 109 | return nil, err 110 | } 111 | lg := w.logger 112 | if attr != nil { 113 | lg = lg.With(*attr) 114 | } 115 | 116 | return wrapConnector(origConnector, lg, w.options.ConnectorOptions), nil 117 | } 118 | 119 | // DriverOpenErrorHandler returns a function that handles errors from driver.Driver.Open. 120 | // The function returns a boolean indicating completion and a slice of slog.Attr. 121 | // 122 | // # For Postgres: 123 | // If err is nil, it returns true and a slice of slog.Attr{slog.Bool("success", true)}. 124 | // If err is io.EOF, it returns true and a slice of slog.Attr{slog.Bool("success", false)}. 125 | // Otherwise, it returns false and nil. 126 | func DriverOpenErrorHandler(driverName string) func(err error) (bool, []slog.Attr) { 127 | switch driverName { 128 | case "postgres": 129 | return func(err error) (bool, []slog.Attr) { 130 | if err == nil { 131 | return true, []slog.Attr{slog.Bool("success", true)} 132 | } 133 | if strings.ToUpper(err.Error()) == "EOF" { 134 | return true, []slog.Attr{slog.Bool("success", false)} 135 | } 136 | return false, nil 137 | } 138 | default: 139 | return nil 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /slog.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | "os" 7 | ) 8 | 9 | type slogOptions struct { 10 | slog.HandlerOptions 11 | handler slog.Handler 12 | handlerFunc func(io.Writer, *slog.HandlerOptions) slog.Handler 13 | logWriter io.Writer 14 | } 15 | 16 | func defaultSlogOptions() *slogOptions { 17 | return &slogOptions{ 18 | handlerFunc: NewTextHandler, 19 | logWriter: os.Stdout, 20 | HandlerOptions: slog.HandlerOptions{}, 21 | } 22 | } 23 | 24 | // Handler sets the slog.Handler to be used. 25 | // If not set, the default is created by HandlerFunc, Writer, SlogOptions. 26 | // If you set this option, HandlerFunc, Writer, SlogOptions will be ignored. 27 | // WARNING: If given handler is created without ReplaceAttr options, 28 | // LevelTrace and LevelVerbose will be logged as DEBUG-4 and DEBUG-8. 29 | func Handler(handler slog.Handler) Option { 30 | return func(o *options) { o.SlogOptions.handler = handler } 31 | } 32 | 33 | // HandlerFunc sets the function to create the slog.Handler. 34 | // If not set, the default is [NewTextHandler]. 35 | // WARNING: Unless given handlerFunc considers ReplaceAttr options like [NewJSONHandler] or [NewTextHandler] of sqlslog package, 36 | // LevelTrace and LevelVerbose will be logged as DEBUG-4 and DEBUG-8. 37 | func HandlerFunc(handlerFunc func(io.Writer, *slog.HandlerOptions) slog.Handler) Option { 38 | return func(o *options) { o.SlogOptions.handlerFunc = handlerFunc } 39 | } 40 | 41 | // LogWriter sets the writer to be used for the slog.Handler. 42 | // If not set, the default is os.Stdout. 43 | func LogWriter(w io.Writer) Option { 44 | return func(o *options) { o.SlogOptions.logWriter = w } 45 | } 46 | 47 | // HandlerOptions sets the options to be used for the slog.Handler. 48 | // If not set, the default is an empty [slog.HandlerOptions]. 49 | func HandlerOptions(opts *slog.HandlerOptions) Option { 50 | return func(o *options) { 51 | if opts == nil { 52 | opts = &slog.HandlerOptions{} 53 | } 54 | o.SlogOptions.HandlerOptions = *opts 55 | } 56 | } 57 | 58 | // AddSource sets whether to add the source to the log. 59 | func AddSource(v bool) Option { 60 | return func(o *options) { o.SlogOptions.AddSource = v } 61 | } 62 | 63 | // LogLevel sets the log level to be used. 64 | func LogLevel(v slog.Leveler) Option { 65 | return func(o *options) { o.SlogOptions.Level = v } 66 | } 67 | 68 | // ReplaceAttr sets the function to replace the attributes. 69 | func LogReplaceAttr(f func([]string, slog.Attr) slog.Attr) Option { 70 | return func(o *options) { o.SlogOptions.ReplaceAttr = f } 71 | } 72 | 73 | // NewJSONHandler returns a new JSON handler using [slog.NewJSONHandler] 74 | // with custom options for sqlslog. 75 | // See [WrapHandlerOptions] for details on the options. 76 | func NewJSONHandler(w io.Writer, opts *slog.HandlerOptions) slog.Handler { 77 | return slog.NewJSONHandler(w, WrapHandlerOptions(opts)) 78 | } 79 | 80 | // NewTextHandler returns a new Text handler using [slog.NewTextHandler] 81 | // with custom options for sqlslog. 82 | // See [WrapHandlerOptions] for details on the options. 83 | func NewTextHandler(w io.Writer, opts *slog.HandlerOptions) slog.Handler { 84 | return slog.NewTextHandler(w, WrapHandlerOptions(opts)) 85 | } 86 | 87 | // WrapHandlerOptions wraps the options with custom options for sqlslog. 88 | // It merges ReplaceAttr functions with [ReplaceLevelAttr]. 89 | func WrapHandlerOptions(opts *slog.HandlerOptions) *slog.HandlerOptions { 90 | if opts == nil { 91 | opts = &slog.HandlerOptions{} 92 | } 93 | opts.ReplaceAttr = MergeReplaceAttrs(opts.ReplaceAttr, ReplaceLevelAttr) 94 | return opts 95 | } 96 | 97 | // ReplaceLevelAttr is a type of ReplaceAttr for [slog.HandlerOptions]. 98 | type ReplaceAttrFunc = func([]string, slog.Attr) slog.Attr 99 | 100 | // MergeReplaceAttrs merges multiple [ReplaceAttrFunc] functions. 101 | // If functions are nil or empty, it returns nil. 102 | // If there is only one function, it returns that function. 103 | // If there are multiple functions, it returns a merged function. 104 | func MergeReplaceAttrs(funcs ...ReplaceAttrFunc) ReplaceAttrFunc { 105 | var valids []ReplaceAttrFunc 106 | for _, f := range funcs { 107 | if f != nil { 108 | valids = append(valids, f) 109 | } 110 | } 111 | if len(valids) == 0 { 112 | return nil 113 | } 114 | if len(valids) == 1 { 115 | return valids[0] 116 | } 117 | return func(group []string, a slog.Attr) slog.Attr { 118 | for _, f := range funcs { 119 | a = f(group, a) 120 | } 121 | return a 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /level_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestLevelString(t *testing.T) { 9 | t.Parallel() 10 | tests := []struct { 11 | baseName string 12 | base Level 13 | diff Level 14 | want string 15 | }{ 16 | {baseName: "LevelVerbose", base: LevelVerbose, diff: -1, want: "VERBOSE-1"}, 17 | {baseName: "LevelVerbose", base: LevelVerbose, diff: 0, want: "VERBOSE"}, 18 | {baseName: "LevelVerbose", base: LevelVerbose, diff: +3, want: "VERBOSE+3"}, 19 | {baseName: "LevelTrace", base: LevelTrace, diff: -1, want: "VERBOSE+3"}, 20 | {baseName: "LevelTrace", base: LevelTrace, diff: 0, want: "TRACE"}, 21 | {baseName: "LevelTrace", base: LevelTrace, diff: 3, want: "TRACE+3"}, 22 | {baseName: "LevelDebug", base: LevelDebug, diff: -1, want: "TRACE+3"}, 23 | {baseName: "LevelDebug", base: LevelDebug, diff: 0, want: "DEBUG"}, 24 | {baseName: "LevelDebug", base: LevelDebug, diff: +3, want: "DEBUG+3"}, 25 | {baseName: "LevelInfo", base: LevelInfo, diff: -1, want: "DEBUG+3"}, 26 | {baseName: "LevelInfo", base: LevelInfo, diff: 0, want: "INFO"}, 27 | {baseName: "LevelInfo", base: LevelInfo, diff: +1, want: "INFO+1"}, 28 | {baseName: "LevelInfo", base: LevelInfo, diff: +3, want: "INFO+3"}, 29 | {baseName: "LevelWarn", base: LevelWarn, diff: -1, want: "INFO+3"}, 30 | {baseName: "LevelWarn", base: LevelWarn, diff: 0, want: "WARN"}, 31 | {baseName: "LevelWarn", base: LevelWarn, diff: +3, want: "WARN+3"}, 32 | {baseName: "LevelError", base: LevelError, diff: -1, want: "WARN+3"}, 33 | {baseName: "LevelError", base: LevelError, diff: 0, want: "ERROR"}, 34 | {baseName: "LevelError", base: LevelError, diff: +1, want: "ERROR+1"}, 35 | } 36 | for _, tt := range tests { 37 | t.Run(fmt.Sprintf("%s%d", tt.baseName, tt.diff), func(t *testing.T) { 38 | t.Parallel() 39 | if got := (tt.base + tt.diff).String(); got != tt.want { 40 | t.Errorf("Level.String() = %v, want %v", got, tt.want) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func TestParseLevel(t *testing.T) { 47 | t.Parallel() 48 | t.Run("Valid case", func(t *testing.T) { 49 | t.Parallel() 50 | tests := []struct { 51 | name string 52 | in string 53 | want Level 54 | }{ 55 | {name: "LevelVerbose", in: "VERBOSE", want: LevelVerbose}, 56 | {name: "LevelTrace", in: "TRACE", want: LevelTrace}, 57 | {name: "LevelDebug", in: "DEBUG", want: LevelDebug}, 58 | {name: "LevelInfo", in: "INFO", want: LevelInfo}, 59 | {name: "LevelWarn", in: "WARN", want: LevelWarn}, 60 | {name: "LevelError", in: "ERROR", want: LevelError}, 61 | } 62 | for _, tt := range tests { 63 | t.Run(tt.name, func(t *testing.T) { 64 | t.Parallel() 65 | if got, err := ParseLevel(tt.in); err != nil || got != tt.want { 66 | t.Errorf("ParseLevel() = %v, %v, want %v, true", got, err, tt.want) 67 | } 68 | }) 69 | } 70 | }) 71 | t.Run("Unknown level", func(t *testing.T) { 72 | t.Parallel() 73 | tests := []string{"", "UNKNOWN", "TRACE+", "TRACE-1", "TRACE+1", "TRACE+1+1"} 74 | for _, in := range tests { 75 | t.Run(in, func(t *testing.T) { 76 | t.Parallel() 77 | lv, err := ParseLevel(in) 78 | if err == nil { 79 | t.Fatal("Expected non-nil") 80 | } 81 | if lv != 0 { 82 | t.Fatalf("Expected 0, got %v", lv) 83 | } 84 | }) 85 | } 86 | }) 87 | } 88 | 89 | func TestParseLevelWithDefault(t *testing.T) { 90 | t.Parallel() 91 | t.Run("Valid case", func(t *testing.T) { 92 | t.Parallel() 93 | tests := []struct { 94 | name string 95 | in string 96 | want Level 97 | }{ 98 | {name: "LevelVerbose", in: "VERBOSE", want: LevelVerbose}, 99 | {name: "LevelTrace", in: "TRACE", want: LevelTrace}, 100 | {name: "LevelDebug", in: "DEBUG", want: LevelDebug}, 101 | {name: "LevelInfo", in: "INFO", want: LevelInfo}, 102 | {name: "LevelWarn", in: "WARN", want: LevelWarn}, 103 | {name: "LevelError", in: "ERROR", want: LevelError}, 104 | } 105 | for _, tt := range tests { 106 | t.Run(tt.name, func(t *testing.T) { 107 | t.Parallel() 108 | if got := ParseLevelWithDefault(tt.in, LevelError); got != tt.want { 109 | t.Errorf("ParseLevelWithDefault() = %v, want %v", got, tt.want) 110 | } 111 | }) 112 | } 113 | }) 114 | t.Run("Unknown level", func(t *testing.T) { 115 | t.Parallel() 116 | tests := []string{"", "UNKNOWN", "TRACE+", "TRACE-1", "TRACE+1", "TRACE+1+1"} 117 | for _, in := range tests { 118 | t.Run(in, func(t *testing.T) { 119 | t.Parallel() 120 | lv := ParseLevelWithDefault(in, LevelError) 121 | if lv != LevelError { 122 | t.Fatalf("Expected LevelError, got %v", lv) 123 | } 124 | }) 125 | } 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /id_gen_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | cryptorand "crypto/rand" 5 | "errors" 6 | "fmt" 7 | "math/rand/v2" 8 | "os" 9 | "slices" 10 | "strconv" 11 | "testing" 12 | ) 13 | 14 | func idGenAttemptsFromEnv(t *testing.T) int { 15 | t.Helper() 16 | idGenAttemptsStr := os.Getenv("ID_GEN_ATTEMPTS") 17 | if idGenAttemptsStr == "" { 18 | idGenAttemptsStr = "1000" 19 | } 20 | idGenAttempts, err := strconv.Atoi(idGenAttemptsStr) 21 | if err != nil { 22 | t.Fatalf("strconv.Atoi: %v", err) 23 | } 24 | return idGenAttempts 25 | } 26 | 27 | func TestIDGeneratorDefault(t *testing.T) { 28 | t.Parallel() 29 | idGenAttempts := idGenAttemptsFromEnv(t) 30 | idGen := IDGeneratorDefault 31 | values := make([]string, idGenAttempts) 32 | for i := range idGenAttempts { 33 | values[i] = idGen() 34 | } 35 | for _, v := range values { 36 | if len(v) != defaultIDLength { 37 | t.Errorf("len(v) = %d, want %d", len(v), defaultIDLength) 38 | } 39 | } 40 | slices.Sort(values) 41 | compactValues := slices.Compact(values) 42 | if len(compactValues) < idGenAttempts { 43 | t.Errorf("len(compactValues) = %d, want %d", len(compactValues), idGenAttempts) 44 | } 45 | } 46 | 47 | func TestRandIntIDGenerator(t *testing.T) { 48 | t.Parallel() 49 | idGenAttempts := idGenAttemptsFromEnv(t) 50 | 51 | testCases := []struct { 52 | length int 53 | }{ 54 | {length: 8}, 55 | {length: 12}, 56 | {length: 16}, 57 | {length: 24}, 58 | } 59 | 60 | for _, tc := range testCases { 61 | t.Run(fmt.Sprintf("length %d", tc.length), func(t *testing.T) { 62 | t.Parallel() 63 | idGen := RandIntIDGenerator( 64 | rand.Int, // Use rand.Int from math/rand/v2 65 | defaultIDLetters, 66 | tc.length, 67 | ) 68 | values := make([]string, idGenAttempts) 69 | for i := range idGenAttempts { 70 | values[i] = idGen() 71 | } 72 | for _, v := range values { 73 | if len(v) != tc.length { 74 | t.Errorf("len(v) = %d, want %d", len(v), tc.length) 75 | } 76 | } 77 | slices.Sort(values) 78 | compactValues := slices.Compact(values) 79 | if len(compactValues) < idGenAttempts { 80 | t.Errorf("len(compactValues) = %d, want %d", len(compactValues), idGenAttempts) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestRandReadGenerator(t *testing.T) { // nolint:gocognit 87 | t.Parallel() 88 | t.Run("valid case", func(t *testing.T) { 89 | t.Parallel() 90 | idGenAttempts := idGenAttemptsFromEnv(t) 91 | 92 | testCases := []struct { 93 | length int 94 | }{ 95 | {length: 8}, 96 | {length: 12}, 97 | {length: 16}, 98 | {length: 24}, 99 | } 100 | 101 | for _, tc := range testCases { 102 | t.Run(fmt.Sprintf("length %d", tc.length), func(t *testing.T) { 103 | t.Parallel() 104 | idGen := RandReadIDGenerator( 105 | cryptorand.Read, // Use rand.Read from crypto/rand 106 | defaultIDLetters, 107 | tc.length, 108 | ) 109 | values := make([]string, idGenAttempts) 110 | for i := range idGenAttempts { 111 | var err error 112 | values[i], err = idGen() 113 | if err != nil { 114 | t.Errorf("idGen: %v", err) 115 | } 116 | } 117 | for _, v := range values { 118 | if len(v) != tc.length { 119 | t.Errorf("len(v) = %d, want %d", len(v), tc.length) 120 | } 121 | } 122 | slices.Sort(values) 123 | compactValues := slices.Compact(values) 124 | if len(compactValues) < idGenAttempts { 125 | t.Errorf("len(compactValues) = %d, want %d", len(compactValues), idGenAttempts) 126 | } 127 | }) 128 | } 129 | }) 130 | 131 | t.Run("with error", func(t *testing.T) { 132 | t.Parallel() 133 | idGen := RandReadIDGenerator( 134 | func([]byte) (int, error) { return 0, errors.New("unexpected error") }, 135 | defaultIDLetters, 136 | defaultIDLength, 137 | ) 138 | t.Run("return error", func(t *testing.T) { 139 | if _, err := idGen(); err == nil { 140 | t.Error("err = nil, want error") 141 | } 142 | }) 143 | t.Run("with suppressor", func(t *testing.T) { 144 | suppressedStr := "suppressed" 145 | t.Run("error", func(t *testing.T) { 146 | suppressedIDGen := IDGenErrorSuppressor(idGen, 147 | func(error) string { return suppressedStr }, 148 | ) 149 | id := suppressedIDGen() 150 | if id != suppressedStr { 151 | t.Errorf("id = %q, want %q", id, suppressedStr) 152 | } 153 | }) 154 | t.Run("no error", func(t *testing.T) { 155 | suppressedIDGen := IDGenErrorSuppressor( 156 | func() (string, error) { return "generated", nil }, 157 | func(error) string { return "suppressed" }, 158 | ) 159 | id := suppressedIDGen() 160 | if id != "generated" { 161 | t.Errorf("id = %q, want %q", id, "generated") 162 | } 163 | }) 164 | }) 165 | }) 166 | } 167 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '42 7 * * 5' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | permissions: 32 | # required for all workflows 33 | security-events: write 34 | 35 | # required to fetch internal or private CodeQL packs 36 | packages: read 37 | 38 | # only required for workflows in private repositories 39 | actions: read 40 | contents: read 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: go 47 | build-mode: autobuild 48 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 49 | # Use `c-cpp` to analyze code written in C, C++ or both 50 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 51 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 52 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 53 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 54 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 55 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | 60 | # Add any setup steps before running the `github/codeql-action/init` action. 61 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 62 | # or others). This is typically only required for manual builds. 63 | # - name: Setup runtime (example) 64 | # uses: actions/setup-example@v1 65 | 66 | # Initializes the CodeQL tools for scanning. 67 | - name: Initialize CodeQL 68 | uses: github/codeql-action/init@v3 69 | with: 70 | languages: ${{ matrix.language }} 71 | build-mode: ${{ matrix.build-mode }} 72 | # If you wish to specify custom queries, you can do so here or in a config file. 73 | # By default, queries listed here will override any specified in a config file. 74 | # Prefix the list here with "+" to use these queries and those in the config file. 75 | 76 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 77 | # queries: security-extended,security-and-quality 78 | 79 | # If the analyze step fails for one of the languages you are analyzing with 80 | # "We were unable to automatically build your code", modify the matrix above 81 | # to set the build mode to "manual" for that language. Then modify this step 82 | # to build your code. 83 | # ℹ️ Command-line programs to run using the OS shell. 84 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 85 | - if: matrix.build-mode == 'manual' 86 | shell: bash 87 | run: | 88 | echo 'If you are using a "manual" build mode for one or more of the' \ 89 | 'languages you are analyzing, replace this with the commands to build' \ 90 | 'your code, for example:' 91 | echo ' make bootstrap' 92 | echo ' make release' 93 | exit 1 94 | 95 | - name: Perform CodeQL Analysis 96 | uses: github/codeql-action/analyze@v3 97 | with: 98 | category: "/language:${{matrix.language}}" 99 | -------------------------------------------------------------------------------- /examples/logs-postgres/results/verbose-log.txt: -------------------------------------------------------------------------------- 1 | time=2025-08-06T23:24:01.733+09:00 level=TRACE msg=Open driver=postgres dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" 2 | time=2025-08-06T23:24:01.734+09:00 level=INFO msg=Open driver=postgres dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=24667 3 | time=2025-08-06T23:24:01.734+09:00 level=TRACE msg=Connector.Connect 4 | time=2025-08-06T23:24:01.734+09:00 level=TRACE msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" 5 | time=2025-08-06T23:24:01.734+09:00 level=ERROR msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=755542 error="read tcp 127.0.0.1:61643->127.0.0.1:5432: read: connection reset by peer" 6 | time=2025-08-06T23:24:01.734+09:00 level=ERROR msg=Connector.Connect duration=787042 error="read tcp 127.0.0.1:61643->127.0.0.1:5432: read: connection reset by peer" 7 | time=2025-08-06T23:24:03.736+09:00 level=TRACE msg=Connector.Connect 8 | time=2025-08-06T23:24:03.736+09:00 level=TRACE msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" 9 | time=2025-08-06T23:24:03.752+09:00 level=INFO msg=Driver.Open dsn="host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable" duration=15959208 conn_id=giWcNRXi0haxjgDO 10 | time=2025-08-06T23:24:03.752+09:00 level=INFO msg=Connector.Connect duration=16194208 11 | time=2025-08-06T23:24:03.752+09:00 level=VERBOSE msg=Conn.Ping conn_id=giWcNRXi0haxjgDO 12 | time=2025-08-06T23:24:03.753+09:00 level=TRACE msg=Conn.Ping conn_id=giWcNRXi0haxjgDO duration=839084 13 | time=2025-08-06T23:24:03.753+09:00 level=VERBOSE msg=Conn.ResetSession conn_id=giWcNRXi0haxjgDO 14 | time=2025-08-06T23:24:03.753+09:00 level=TRACE msg=Conn.ResetSession conn_id=giWcNRXi0haxjgDO duration=1458 15 | time=2025-08-06T23:24:03.753+09:00 level=TRACE msg=Conn.ExecContext conn_id=giWcNRXi0haxjgDO query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] 16 | time=2025-08-06T23:24:03.758+09:00 level=INFO msg=Conn.ExecContext conn_id=giWcNRXi0haxjgDO query="CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))" args=[] duration=4599667 17 | time=2025-08-06T23:24:03.758+09:00 level=VERBOSE msg=Conn.ResetSession conn_id=giWcNRXi0haxjgDO 18 | time=2025-08-06T23:24:03.758+09:00 level=TRACE msg=Conn.ResetSession conn_id=giWcNRXi0haxjgDO duration=667 19 | time=2025-08-06T23:24:03.758+09:00 level=VERBOSE msg=Conn.Ping conn_id=giWcNRXi0haxjgDO 20 | time=2025-08-06T23:24:03.759+09:00 level=TRACE msg=Conn.Ping conn_id=giWcNRXi0haxjgDO duration=958542 21 | time=2025-08-06T23:24:03.759+09:00 level=VERBOSE msg=Conn.ResetSession conn_id=giWcNRXi0haxjgDO 22 | time=2025-08-06T23:24:03.759+09:00 level=TRACE msg=Conn.ResetSession conn_id=giWcNRXi0haxjgDO duration=334 23 | time=2025-08-06T23:24:03.759+09:00 level=TRACE msg=Conn.BeginTx conn_id=giWcNRXi0haxjgDO 24 | time=2025-08-06T23:24:03.760+09:00 level=INFO msg=Conn.BeginTx conn_id=giWcNRXi0haxjgDO duration=493125 tx_id=_0Hz9N_J4DB8AcoZ 25 | time=2025-08-06T23:24:03.760+09:00 level=TRACE msg=Conn.ExecContext conn_id=giWcNRXi0haxjgDO query="INSERT INTO test1 (id, name) VALUES ($1,$2);" args="[1,\"Alice\"]" 26 | time=2025-08-06T23:24:03.761+09:00 level=INFO msg=Conn.ExecContext conn_id=giWcNRXi0haxjgDO query="INSERT INTO test1 (id, name) VALUES ($1,$2);" args="[1,\"Alice\"]" duration=1293791 27 | time=2025-08-06T23:24:03.761+09:00 level=TRACE msg=Tx.Commit conn_id=giWcNRXi0haxjgDO tx_id=_0Hz9N_J4DB8AcoZ 28 | time=2025-08-06T23:24:03.762+09:00 level=INFO msg=Tx.Commit conn_id=giWcNRXi0haxjgDO tx_id=_0Hz9N_J4DB8AcoZ duration=1052833 29 | time=2025-08-06T23:24:03.762+09:00 level=VERBOSE msg=Conn.ResetSession conn_id=giWcNRXi0haxjgDO 30 | time=2025-08-06T23:24:03.762+09:00 level=TRACE msg=Conn.ResetSession conn_id=giWcNRXi0haxjgDO duration=375 31 | time=2025-08-06T23:24:03.762+09:00 level=TRACE msg=Conn.QueryContext conn_id=giWcNRXi0haxjgDO query="SELECT * FROM test1" args=[] 32 | time=2025-08-06T23:24:03.763+09:00 level=DEBUG msg=Conn.QueryContext conn_id=giWcNRXi0haxjgDO query="SELECT * FROM test1" args=[] duration=645417 33 | time=2025-08-06T23:24:03.763+09:00 level=VERBOSE msg=Rows.Next conn_id=giWcNRXi0haxjgDO 34 | time=2025-08-06T23:24:03.763+09:00 level=DEBUG msg=Rows.Next conn_id=giWcNRXi0haxjgDO duration=2625 eof=false 35 | time=2025-08-06T23:24:03.763+09:00 level=INFO msg=Record id=1 name=Alice 36 | time=2025-08-06T23:24:03.763+09:00 level=VERBOSE msg=Rows.Next conn_id=giWcNRXi0haxjgDO 37 | time=2025-08-06T23:24:03.763+09:00 level=DEBUG msg=Rows.Next conn_id=giWcNRXi0haxjgDO duration=1584 eof=true 38 | time=2025-08-06T23:24:03.763+09:00 level=VERBOSE msg=Rows.Close conn_id=giWcNRXi0haxjgDO 39 | time=2025-08-06T23:24:03.763+09:00 level=DEBUG msg=Rows.Close conn_id=giWcNRXi0haxjgDO duration=292 40 | time=2025-08-06T23:24:03.763+09:00 level=TRACE msg=Conn.Close conn_id=giWcNRXi0haxjgDO 41 | time=2025-08-06T23:24:03.763+09:00 level=INFO msg=Conn.Close conn_id=giWcNRXi0haxjgDO duration=70833 42 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | type options struct { 4 | stepLoggerOptions 5 | DriverOptions *driverOptions 6 | SlogOptions *slogOptions 7 | Open StepOptions 8 | } 9 | 10 | func newDefaultOptions(driverName string, msgb StepEventMsgBuilder) *options { 11 | return &options{ 12 | stepLoggerOptions: defaultStepLoggerOptions(), 13 | DriverOptions: defaultDriverOptions(driverName, msgb), 14 | SlogOptions: defaultSlogOptions(), 15 | Open: *defaultStepOptions(msgb, StepSqlslogOpen, LevelInfo), 16 | } 17 | } 18 | 19 | // Option is a function that sets an option on the options struct. 20 | type Option func(*options) 21 | 22 | var stepEventMsgBuilder = StepEventMsgWithoutEventName 23 | 24 | // SetStepEventMsgBuilder sets the builder for the step event message used in logs. 25 | // If not set, the default is StepLogMsgWithEventName. 26 | func SetStepEventMsgBuilder(f StepEventMsgBuilder) { stepEventMsgBuilder = f } 27 | 28 | func newOptions(driverName string, opts ...Option) *options { 29 | o := newDefaultOptions(driverName, stepEventMsgBuilder) 30 | for _, opt := range opts { 31 | opt(o) 32 | } 33 | return o 34 | } 35 | 36 | // Set the options for Conn.Begin. 37 | func ConnBegin(f func(*StepOptions)) Option { 38 | return func(o *options) { f(&o.DriverOptions.ConnOptions.Begin) } 39 | } 40 | 41 | // Set the options for Conn.Close. 42 | func ConnClose(f func(*StepOptions)) Option { 43 | return func(o *options) { f(&o.DriverOptions.ConnOptions.Close) } 44 | } 45 | 46 | // Set the options for Conn.Prepare. 47 | func ConnPrepare(f func(*StepOptions)) Option { 48 | return func(o *options) { f(&o.DriverOptions.ConnOptions.Prepare) } 49 | } 50 | 51 | // Set the options for Conn.ResetSession. 52 | func ConnResetSession(f func(*StepOptions)) Option { 53 | return func(o *options) { f(&o.DriverOptions.ConnOptions.ResetSession) } 54 | } 55 | 56 | // Set the options for Conn.Ping. 57 | func ConnPing(f func(*StepOptions)) Option { 58 | return func(o *options) { f(&o.DriverOptions.ConnOptions.Ping) } 59 | } 60 | 61 | // Set the options for Conn.ExecContext. 62 | func ConnExecContext(f func(*StepOptions)) Option { 63 | return func(o *options) { f(&o.DriverOptions.ConnOptions.ExecContext) } 64 | } 65 | 66 | // Set the options for Conn.QueryContext. 67 | func ConnQueryContext(f func(*StepOptions)) Option { 68 | return func(o *options) { f(&o.DriverOptions.ConnOptions.QueryContext) } 69 | } 70 | 71 | // Set the options for Conn.PrepareContext. 72 | func ConnPrepareContext(f func(*StepOptions)) Option { 73 | return func(o *options) { f(&o.DriverOptions.ConnOptions.PrepareContext) } 74 | } 75 | 76 | // Set the options for Conn.BeginTx. 77 | func ConnBeginTx(f func(*StepOptions)) Option { 78 | return func(o *options) { f(&o.DriverOptions.ConnOptions.BeginTx) } 79 | } 80 | 81 | // Set the options for Connector.Connect. 82 | func ConnectorConnect(f func(*StepOptions)) Option { 83 | return func(o *options) { f(&o.DriverOptions.ConnectorOptions.Connect) } 84 | } 85 | 86 | // Set the options for Driver.Open. 87 | func DriverOpen(f func(*StepOptions)) Option { return func(o *options) { f(&o.DriverOptions.Open) } } 88 | 89 | // Set the options for Driver.OpenConnector. 90 | func DriverOpenConnector(f func(*StepOptions)) Option { 91 | return func(o *options) { f(&o.DriverOptions.OpenConnector) } 92 | } 93 | 94 | // Set the options for sqlslog.Open. 95 | func SqlslogOpen(f func(*StepOptions)) Option { return func(o *options) { f(&o.Open) } } // nolint:revive 96 | 97 | // Set the options for Rows.Close. 98 | func RowsClose(f func(*StepOptions)) Option { 99 | return func(o *options) { f(&o.DriverOptions.ConnOptions.RowsOptions.Close) } 100 | } 101 | 102 | // Set the options for Rows.Next. 103 | func RowsNext(f func(*StepOptions)) Option { 104 | return func(o *options) { f(&o.DriverOptions.ConnOptions.RowsOptions.Next) } 105 | } 106 | 107 | // Set the options for Rows.NextResultSet. 108 | func RowsNextResultSet(f func(*StepOptions)) Option { 109 | return func(o *options) { f(&o.DriverOptions.ConnOptions.RowsOptions.NextResultSet) } 110 | } 111 | 112 | // Set the options for Stmt.Close. 113 | func StmtClose(f func(*StepOptions)) Option { 114 | return func(o *options) { f(&o.DriverOptions.ConnOptions.StmtOptions.Close) } 115 | } 116 | 117 | // Set the options for Stmt.Exec. 118 | func StmtExec(f func(*StepOptions)) Option { 119 | return func(o *options) { f(&o.DriverOptions.ConnOptions.StmtOptions.Exec) } 120 | } 121 | 122 | // Set the options for Stmt.Query. 123 | func StmtQuery(f func(*StepOptions)) Option { 124 | return func(o *options) { f(&o.DriverOptions.ConnOptions.StmtOptions.Query) } 125 | } 126 | 127 | // Set the options for Stmt.ExecContext. 128 | func StmtExecContext(f func(*StepOptions)) Option { 129 | return func(o *options) { f(&o.DriverOptions.ConnOptions.StmtOptions.ExecContext) } 130 | } 131 | 132 | // Set the options for Stmt.QueryContext. 133 | func StmtQueryContext(f func(*StepOptions)) Option { 134 | return func(o *options) { f(&o.DriverOptions.ConnOptions.StmtOptions.QueryContext) } 135 | } 136 | 137 | // Set the options for Tx.Commit. 138 | func TxCommit(f func(*StepOptions)) Option { 139 | return func(o *options) { f(&o.DriverOptions.ConnOptions.TxOptions.Commit) } 140 | } 141 | 142 | // Set the options for Tx.Rollback. 143 | func TxRollback(f func(*StepOptions)) Option { 144 | return func(o *options) { f(&o.DriverOptions.ConnOptions.TxOptions.Rollback) } 145 | } 146 | -------------------------------------------------------------------------------- /rows.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "database/sql/driver" 5 | "errors" 6 | "io" 7 | "log/slog" 8 | "reflect" 9 | ) 10 | 11 | type rowsOptions struct { 12 | Close StepOptions 13 | Next StepOptions 14 | NextResultSet StepOptions 15 | } 16 | 17 | func defaultRowsOptions(msgb StepEventMsgBuilder) *rowsOptions { 18 | return &rowsOptions{ 19 | Close: *defaultStepOptions(msgb, StepRowsClose, LevelDebug), 20 | Next: *defaultStepOptions(msgb, StepRowsNext, LevelDebug, HandleRowsNextError), 21 | NextResultSet: *defaultStepOptions(msgb, StepRowsNextResultSet, LevelDebug), 22 | } 23 | } 24 | 25 | func wrapRows(original driver.Rows, logger *stepLogger, options *rowsOptions) driver.Rows { 26 | if original == nil { 27 | return nil 28 | } 29 | rw := rowsWrapper{original: original, logger: logger, options: options} 30 | if rnrs, ok := original.(driver.RowsNextResultSet); ok { 31 | return &rowsNextResultSetWrapper{rw, rnrs} 32 | } 33 | return &rw 34 | } 35 | 36 | type rowsWrapper struct { 37 | original driver.Rows 38 | logger *stepLogger 39 | options *rowsOptions 40 | } 41 | 42 | var _ driver.Rows = (*rowsWrapper)(nil) 43 | 44 | // Close implements driver.Rows. 45 | func (r *rowsWrapper) Close() error { 46 | return ignoreAttr(r.logger.StepWithoutContext(&r.options.Close, withNilAttr(r.original.Close))) 47 | } 48 | 49 | // Columns implements driver.Rows. 50 | func (r *rowsWrapper) Columns() []string { 51 | return r.original.Columns() 52 | } 53 | 54 | // Next implements driver.Rows. 55 | func (r *rowsWrapper) Next(dest []driver.Value) error { 56 | return ignoreAttr(r.logger.StepWithoutContext(&r.options.Next, func() (*slog.Attr, error) { 57 | return nil, r.original.Next(dest) 58 | })) 59 | } 60 | 61 | // If the driver knows how to describe the types 62 | // present in the returned result, it should implement the following 63 | // interfaces: RowsColumnTypeScanType, RowsColumnTypeDatabaseTypeName, 64 | // RowsColumnTypeLength, RowsColumnTypeNullable, and 65 | // RowsColumnTypePrecisionScale. A given row value may also return a 66 | // Rows type, which may represent a database cursor value. 67 | // 68 | // These are used in database/sql/sql.go 69 | // https://cs.opensource.google/go/go/+/master:src/database/sql/sql.go;l=3284-3300 70 | 71 | var ( 72 | _ driver.RowsColumnTypeScanType = (*rowsWrapper)(nil) 73 | _ driver.RowsColumnTypeDatabaseTypeName = (*rowsWrapper)(nil) 74 | _ driver.RowsColumnTypeLength = (*rowsWrapper)(nil) 75 | _ driver.RowsColumnTypeNullable = (*rowsWrapper)(nil) 76 | _ driver.RowsColumnTypePrecisionScale = (*rowsWrapper)(nil) 77 | ) 78 | 79 | // ColumnTypeScanType implements driver.RowsColumnTypeScanType. 80 | func (r *rowsWrapper) ColumnTypeScanType(index int) reflect.Type { 81 | // https://cs.opensource.google/go/go/+/master:src/database/sql/sql.go;l=3284-3288 82 | if c, ok := r.original.(driver.RowsColumnTypeScanType); ok { 83 | return c.ColumnTypeScanType(index) 84 | } 85 | return reflect.TypeFor[any]() 86 | } 87 | 88 | // ColumnTypeDatabaseTypeName implements driver.RowsColumnTypeDatabaseTypeName. 89 | func (r *rowsWrapper) ColumnTypeDatabaseTypeName(index int) string { 90 | if c, ok := r.original.(driver.RowsColumnTypeDatabaseTypeName); ok { 91 | return c.ColumnTypeDatabaseTypeName(index) 92 | } 93 | return "" 94 | } 95 | 96 | // ColumnTypeLength implements driver.RowsColumnTypeLength. 97 | func (r *rowsWrapper) ColumnTypeLength(index int) (int64, bool) { 98 | if c, ok := r.original.(driver.RowsColumnTypeLength); ok { 99 | return c.ColumnTypeLength(index) 100 | } 101 | return 0, false 102 | } 103 | 104 | // ColumnTypeNullable implements driver.RowsColumnTypeNullable. 105 | func (r *rowsWrapper) ColumnTypeNullable(index int) (bool, bool) { 106 | if c, ok := r.original.(driver.RowsColumnTypeNullable); ok { 107 | return c.ColumnTypeNullable(index) 108 | } 109 | return false, false 110 | } 111 | 112 | // ColumnTypePrecisionScale implements driver.RowsColumnTypePrecisionScale. 113 | func (r *rowsWrapper) ColumnTypePrecisionScale(index int) (int64, int64, bool) { 114 | if c, ok := r.original.(driver.RowsColumnTypePrecisionScale); ok { 115 | return c.ColumnTypePrecisionScale(index) 116 | } 117 | return 0, 0, false 118 | } 119 | 120 | type rowsNextResultSetWrapper struct { 121 | rowsWrapper 122 | original driver.RowsNextResultSet 123 | } 124 | 125 | // If multiple result sets are supported, Rows should implement 126 | // RowsNextResultSet. 127 | var _ driver.RowsNextResultSet = (*rowsNextResultSetWrapper)(nil) 128 | 129 | // HasNextResultSet implements driver.RowsNextResultSet. 130 | func (r *rowsNextResultSetWrapper) HasNextResultSet() bool { 131 | return r.original.HasNextResultSet() 132 | } 133 | 134 | // NextResultSet implements driver.RowsNextResultSet. 135 | func (r *rowsNextResultSetWrapper) NextResultSet() error { 136 | return ignoreAttr( 137 | r.logger.StepWithoutContext( 138 | &r.options.NextResultSet, 139 | withNilAttr(r.original.NextResultSet), 140 | ), 141 | ) 142 | } 143 | 144 | // HandleRowsNextError returns a boolean indicating completion and a slice of slog.Attr. 145 | // If err is nil, it returns true and a slice of slog.Attr{slog.Bool("eof", false)}. 146 | // If err is io.EOF, it returns true and a slice of slog.Attr{slog.Bool("eof", true)}. 147 | // Otherwise, it returns false and nil. 148 | func HandleRowsNextError(err error) (bool, []slog.Attr) { 149 | if err == nil { 150 | return true, []slog.Attr{slog.Bool("eof", false)} 151 | } 152 | if errors.Is(err, io.EOF) { 153 | return true, []slog.Attr{slog.Bool("eof", true)} 154 | } 155 | return false, nil 156 | } 157 | -------------------------------------------------------------------------------- /examples/with-go-requestid/handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "log/slog" 8 | "net/http" 9 | "strconv" 10 | ) 11 | 12 | var db *sql.DB 13 | 14 | type Todo struct { 15 | ID int `json:"id"` 16 | Title string `json:"title"` 17 | Status string `json:"status"` 18 | } 19 | 20 | func createTable(ctx context.Context) { 21 | query := ` 22 | CREATE TABLE todos ( 23 | id INTEGER PRIMARY KEY AUTOINCREMENT, 24 | title TEXT, 25 | status TEXT 26 | );` 27 | _, err := db.ExecContext(ctx, query) 28 | if err != nil { 29 | slog.ErrorContext(ctx, "Failed to create table", "error", err) 30 | return 31 | } 32 | slog.InfoContext(ctx, "Table created successfully") 33 | } 34 | 35 | func getTodos(w http.ResponseWriter, r *http.Request) { 36 | ctx := r.Context() 37 | slog.InfoContext(ctx, "getTodos handler started") 38 | defer slog.InfoContext(ctx, "getTodos handler ended") 39 | 40 | rows, err := db.QueryContext(ctx, "SELECT id, title, status FROM todos") 41 | if err != nil { 42 | slog.ErrorContext(ctx, "Error querying todos", "error", err) 43 | http.Error(w, err.Error(), http.StatusInternalServerError) 44 | return 45 | } 46 | defer rows.Close() 47 | 48 | var todos []Todo 49 | for rows.Next() { 50 | var todo Todo 51 | if err := rows.Scan(&todo.ID, &todo.Title, &todo.Status); err != nil { 52 | slog.ErrorContext(ctx, "Error scanning todo", "error", err) 53 | http.Error(w, err.Error(), http.StatusInternalServerError) 54 | return 55 | } 56 | todos = append(todos, todo) 57 | } 58 | 59 | w.Header().Set("Content-Type", "application/json") 60 | json.NewEncoder(w).Encode(todos) 61 | } 62 | 63 | func createTodo(w http.ResponseWriter, r *http.Request) { 64 | ctx := r.Context() 65 | slog.InfoContext(ctx, "createTodo handler started") 66 | defer slog.InfoContext(ctx, "createTodo handler ended") 67 | 68 | var todo Todo 69 | if err := json.NewDecoder(r.Body).Decode(&todo); err != nil { 70 | slog.ErrorContext(ctx, "Error decoding todo", "error", err) 71 | http.Error(w, err.Error(), http.StatusBadRequest) 72 | return 73 | } 74 | 75 | result, err := db.ExecContext(ctx, "INSERT INTO todos (title, status) VALUES (?, ?)", todo.Title, todo.Status) 76 | if err != nil { 77 | slog.ErrorContext(ctx, "Error inserting todo", "error", err) 78 | http.Error(w, err.Error(), http.StatusInternalServerError) 79 | return 80 | } 81 | 82 | id, err := result.LastInsertId() 83 | if err != nil { 84 | slog.ErrorContext(ctx, "Error getting last insert ID", "error", err) 85 | http.Error(w, err.Error(), http.StatusInternalServerError) 86 | return 87 | } 88 | 89 | todo.ID = int(id) 90 | w.Header().Set("Content-Type", "application/json") 91 | json.NewEncoder(w).Encode(todo) 92 | } 93 | 94 | func getTodoByID(w http.ResponseWriter, r *http.Request) { 95 | ctx := r.Context() 96 | slog.InfoContext(ctx, "getTodoByID handler started") 97 | defer slog.InfoContext(ctx, "getTodoByID handler ended") 98 | 99 | idStr := r.PathValue("id") 100 | id, err := strconv.Atoi(idStr) 101 | if err != nil { 102 | slog.ErrorContext(ctx, "Invalid ID", "error", err) 103 | http.Error(w, "Invalid ID", http.StatusNotFound) 104 | return 105 | } 106 | 107 | var todo Todo 108 | if err := db.QueryRowContext(ctx, "SELECT id, title, status FROM todos WHERE id = ?", id).Scan(&todo.ID, &todo.Title, &todo.Status); err != nil { 109 | if err == sql.ErrNoRows { 110 | slog.InfoContext(ctx, "Todo not found") 111 | http.Error(w, "Todo not found", http.StatusNotFound) 112 | } else { 113 | slog.ErrorContext(ctx, "Error querying todo by ID", "error", err) 114 | http.Error(w, err.Error(), http.StatusInternalServerError) 115 | } 116 | return 117 | } 118 | 119 | w.Header().Set("Content-Type", "application/json") 120 | json.NewEncoder(w).Encode(todo) 121 | } 122 | 123 | func updateTodoByID(w http.ResponseWriter, r *http.Request) { 124 | ctx := r.Context() 125 | slog.InfoContext(ctx, "updateTodoByID handler started") 126 | defer slog.InfoContext(ctx, "updateTodoByID handler ended") 127 | 128 | idStr := r.PathValue("id") 129 | id, err := strconv.Atoi(idStr) 130 | if err != nil { 131 | slog.ErrorContext(ctx, "Invalid ID", "error", err) 132 | http.Error(w, "Invalid ID", http.StatusNotFound) 133 | return 134 | } 135 | 136 | var todo Todo 137 | if err := json.NewDecoder(r.Body).Decode(&todo); err != nil { 138 | slog.ErrorContext(ctx, "Error decoding todo", "error", err) 139 | http.Error(w, err.Error(), http.StatusBadRequest) 140 | return 141 | } 142 | 143 | if _, err := db.ExecContext(ctx, "UPDATE todos SET title = ?, status = ? WHERE id = ?", todo.Title, todo.Status, id); err != nil { 144 | slog.ErrorContext(ctx, "Error updating todo", "error", err) 145 | http.Error(w, err.Error(), http.StatusInternalServerError) 146 | return 147 | } 148 | 149 | todo.ID = id 150 | w.Header().Set("Content-Type", "application/json") 151 | json.NewEncoder(w).Encode(todo) 152 | } 153 | 154 | func deleteTodoByID(w http.ResponseWriter, r *http.Request) { 155 | ctx := r.Context() 156 | slog.InfoContext(ctx, "deleteTodoByID handler started") 157 | defer slog.InfoContext(ctx, "deleteTodoByID handler ended") 158 | 159 | idStr := r.PathValue("id") 160 | id, err := strconv.Atoi(idStr) 161 | if err != nil { 162 | slog.ErrorContext(ctx, "Invalid ID", "error", err) 163 | http.Error(w, "Invalid ID", http.StatusNotFound) 164 | return 165 | } 166 | 167 | if _, err := db.ExecContext(ctx, "DELETE FROM todos WHERE id = ?", id); err != nil { 168 | slog.ErrorContext(ctx, "Error deleting todo", "error", err) 169 | http.Error(w, err.Error(), http.StatusInternalServerError) 170 | return 171 | } 172 | 173 | w.WriteHeader(http.StatusNoContent) 174 | } 175 | -------------------------------------------------------------------------------- /examples/logs-postgres/results/verbose-log.json: -------------------------------------------------------------------------------- 1 | {"time":"2025-08-06T23:24:04.554997+09:00","level":"TRACE","msg":"Open","driver":"postgres","dsn":"host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable"} 2 | {"time":"2025-08-06T23:24:04.555138+09:00","level":"INFO","msg":"Open","driver":"postgres","dsn":"host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable","duration":12916} 3 | {"time":"2025-08-06T23:24:04.555143+09:00","level":"TRACE","msg":"Connector.Connect"} 4 | {"time":"2025-08-06T23:24:04.555151+09:00","level":"TRACE","msg":"Driver.Open","dsn":"host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable"} 5 | {"time":"2025-08-06T23:24:04.555738+09:00","level":"ERROR","msg":"Driver.Open","dsn":"host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable","duration":579875,"error":"read tcp 127.0.0.1:61648->127.0.0.1:5432: read: connection reset by peer"} 6 | {"time":"2025-08-06T23:24:04.55575+09:00","level":"ERROR","msg":"Connector.Connect","duration":603208,"error":"read tcp 127.0.0.1:61648->127.0.0.1:5432: read: connection reset by peer"} 7 | {"time":"2025-08-06T23:24:06.556979+09:00","level":"TRACE","msg":"Connector.Connect"} 8 | {"time":"2025-08-06T23:24:06.55746+09:00","level":"TRACE","msg":"Driver.Open","dsn":"host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable"} 9 | {"time":"2025-08-06T23:24:06.577368+09:00","level":"INFO","msg":"Driver.Open","dsn":"host=127.0.0.1 port=5432 user=root password=password dbname=app1 sslmode=disable","duration":19805625,"conn_id":"b1n_lbHcZoZNvvGM"} 10 | {"time":"2025-08-06T23:24:06.577485+09:00","level":"INFO","msg":"Connector.Connect","duration":20099625} 11 | {"time":"2025-08-06T23:24:06.577564+09:00","level":"VERBOSE","msg":"Conn.Ping","conn_id":"b1n_lbHcZoZNvvGM"} 12 | {"time":"2025-08-06T23:24:06.578624+09:00","level":"TRACE","msg":"Conn.Ping","conn_id":"b1n_lbHcZoZNvvGM","duration":1006917} 13 | {"time":"2025-08-06T23:24:06.578728+09:00","level":"VERBOSE","msg":"Conn.ResetSession","conn_id":"b1n_lbHcZoZNvvGM"} 14 | {"time":"2025-08-06T23:24:06.578778+09:00","level":"TRACE","msg":"Conn.ResetSession","conn_id":"b1n_lbHcZoZNvvGM","duration":1417} 15 | {"time":"2025-08-06T23:24:06.578824+09:00","level":"TRACE","msg":"Conn.ExecContext","conn_id":"b1n_lbHcZoZNvvGM","query":"CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))","args":"[]"} 16 | {"time":"2025-08-06T23:24:06.586314+09:00","level":"INFO","msg":"Conn.ExecContext","conn_id":"b1n_lbHcZoZNvvGM","query":"CREATE TABLE IF NOT EXISTS test1 (id INTEGER PRIMARY KEY, name VARCHAR(255))","args":"[]","duration":7430708} 17 | {"time":"2025-08-06T23:24:06.586417+09:00","level":"VERBOSE","msg":"Conn.ResetSession","conn_id":"b1n_lbHcZoZNvvGM"} 18 | {"time":"2025-08-06T23:24:06.586452+09:00","level":"TRACE","msg":"Conn.ResetSession","conn_id":"b1n_lbHcZoZNvvGM","duration":1000} 19 | {"time":"2025-08-06T23:24:06.586476+09:00","level":"VERBOSE","msg":"Conn.Ping","conn_id":"b1n_lbHcZoZNvvGM"} 20 | {"time":"2025-08-06T23:24:06.587181+09:00","level":"TRACE","msg":"Conn.Ping","conn_id":"b1n_lbHcZoZNvvGM","duration":678208} 21 | {"time":"2025-08-06T23:24:06.587244+09:00","level":"VERBOSE","msg":"Conn.ResetSession","conn_id":"b1n_lbHcZoZNvvGM"} 22 | {"time":"2025-08-06T23:24:06.587271+09:00","level":"TRACE","msg":"Conn.ResetSession","conn_id":"b1n_lbHcZoZNvvGM","duration":500} 23 | {"time":"2025-08-06T23:24:06.587295+09:00","level":"TRACE","msg":"Conn.BeginTx","conn_id":"b1n_lbHcZoZNvvGM"} 24 | {"time":"2025-08-06T23:24:06.587763+09:00","level":"INFO","msg":"Conn.BeginTx","conn_id":"b1n_lbHcZoZNvvGM","duration":452750,"tx_id":"dtuWOqXSssYXMdST"} 25 | {"time":"2025-08-06T23:24:06.58783+09:00","level":"TRACE","msg":"Conn.ExecContext","conn_id":"b1n_lbHcZoZNvvGM","query":"INSERT INTO test1 (id, name) VALUES ($1,$2);","args":"[1,\"Alice\"]"} 26 | {"time":"2025-08-06T23:24:06.589123+09:00","level":"INFO","msg":"Conn.ExecContext","conn_id":"b1n_lbHcZoZNvvGM","query":"INSERT INTO test1 (id, name) VALUES ($1,$2);","args":"[1,\"Alice\"]","duration":1276375} 27 | {"time":"2025-08-06T23:24:06.589214+09:00","level":"TRACE","msg":"Tx.Commit","conn_id":"b1n_lbHcZoZNvvGM","tx_id":"dtuWOqXSssYXMdST"} 28 | {"time":"2025-08-06T23:24:06.58994+09:00","level":"INFO","msg":"Tx.Commit","conn_id":"b1n_lbHcZoZNvvGM","tx_id":"dtuWOqXSssYXMdST","duration":674417} 29 | {"time":"2025-08-06T23:24:06.589965+09:00","level":"VERBOSE","msg":"Conn.ResetSession","conn_id":"b1n_lbHcZoZNvvGM"} 30 | {"time":"2025-08-06T23:24:06.589976+09:00","level":"TRACE","msg":"Conn.ResetSession","conn_id":"b1n_lbHcZoZNvvGM","duration":334} 31 | {"time":"2025-08-06T23:24:06.589992+09:00","level":"TRACE","msg":"Conn.QueryContext","conn_id":"b1n_lbHcZoZNvvGM","query":"SELECT * FROM test1","args":"[]"} 32 | {"time":"2025-08-06T23:24:06.590399+09:00","level":"DEBUG","msg":"Conn.QueryContext","conn_id":"b1n_lbHcZoZNvvGM","query":"SELECT * FROM test1","args":"[]","duration":398375} 33 | {"time":"2025-08-06T23:24:06.590422+09:00","level":"VERBOSE","msg":"Rows.Next","conn_id":"b1n_lbHcZoZNvvGM"} 34 | {"time":"2025-08-06T23:24:06.590449+09:00","level":"DEBUG","msg":"Rows.Next","conn_id":"b1n_lbHcZoZNvvGM","duration":3083,"eof":false} 35 | {"time":"2025-08-06T23:24:06.590481+09:00","level":"INFO","msg":"Record","id":1,"name":"Alice"} 36 | {"time":"2025-08-06T23:24:06.5905+09:00","level":"VERBOSE","msg":"Rows.Next","conn_id":"b1n_lbHcZoZNvvGM"} 37 | {"time":"2025-08-06T23:24:06.590509+09:00","level":"DEBUG","msg":"Rows.Next","conn_id":"b1n_lbHcZoZNvvGM","duration":1292,"eof":true} 38 | {"time":"2025-08-06T23:24:06.590518+09:00","level":"VERBOSE","msg":"Rows.Close","conn_id":"b1n_lbHcZoZNvvGM"} 39 | {"time":"2025-08-06T23:24:06.590525+09:00","level":"DEBUG","msg":"Rows.Close","conn_id":"b1n_lbHcZoZNvvGM","duration":334} 40 | {"time":"2025-08-06T23:24:06.590539+09:00","level":"TRACE","msg":"Conn.Close","conn_id":"b1n_lbHcZoZNvvGM"} 41 | {"time":"2025-08-06T23:24:06.590575+09:00","level":"INFO","msg":"Conn.Close","conn_id":"b1n_lbHcZoZNvvGM","duration":30167} 42 | -------------------------------------------------------------------------------- /conn_test.go: -------------------------------------------------------------------------------- 1 | package sqlslog 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "errors" 7 | "log/slog" 8 | "testing" 9 | ) 10 | 11 | type mockConnForWrapConn struct{} 12 | 13 | // Begin implements driver.Conn. 14 | func (m *mockConnForWrapConn) Begin() (driver.Tx, error) { 15 | panic("unimplemented") 16 | } 17 | 18 | // Close implements driver.Conn. 19 | func (m *mockConnForWrapConn) Close() error { 20 | panic("unimplemented") 21 | } 22 | 23 | // Prepare implements driver.Conn. 24 | func (m *mockConnForWrapConn) Prepare(string) (driver.Stmt, error) { 25 | panic("unimplemented") 26 | } 27 | 28 | var _ driver.Conn = (*mockConnForWrapConn)(nil) 29 | 30 | func TestWrapConn(t *testing.T) { 31 | t.Parallel() 32 | t.Run("nil", func(t *testing.T) { 33 | t.Parallel() 34 | if wrapConn(nil, nil, nil) != nil { 35 | t.Fatal("Expected nil") 36 | } 37 | }) 38 | t.Run("implements driver.Conn but not connWithContext", func(t *testing.T) { 39 | t.Parallel() 40 | mock := &mockConnForWrapConn{} 41 | logger := &stepLogger{} 42 | connOptions := defaultConnOptions("dummy", StepEventMsgWithoutEventName) 43 | conn := wrapConn(mock, logger, connOptions) 44 | if conn == nil { 45 | t.Fatal("Expected non-nil") 46 | } 47 | 48 | t.Run("skip wrapped driver.Conn object", func(t *testing.T) { 49 | res := wrapConn(conn, logger, connOptions) 50 | if res != conn { 51 | t.Fatal("Expected same object") 52 | } 53 | }) 54 | }) 55 | } 56 | 57 | func TestConnExecContextErrorHandler(t *testing.T) { 58 | t.Parallel() 59 | errHandler := ConnExecContextErrorHandler("mysql") 60 | complete, attrs := errHandler(errors.New("dummy")) 61 | if complete { 62 | t.Fatal("Expected false") 63 | } 64 | if attrs != nil { 65 | t.Fatal("Expected nil") 66 | } 67 | } 68 | 69 | func TestConnQueryContextErrorHandler(t *testing.T) { 70 | t.Parallel() 71 | t.Run("mysql", func(t *testing.T) { 72 | t.Parallel() 73 | errHandler := ConnQueryContextErrorHandler("mysql") 74 | t.Run("nil error", func(t *testing.T) { 75 | t.Parallel() 76 | complete, attrs := errHandler(nil) 77 | if !complete { 78 | t.Fatal("Expected true") 79 | } 80 | if attrs != nil { 81 | t.Fatal("Expected nil") 82 | } 83 | }) 84 | t.Run("unexpected error", func(t *testing.T) { 85 | t.Parallel() 86 | complete, attrs := errHandler(errors.New("dummy")) 87 | if complete { 88 | t.Fatal("Expected false") 89 | } 90 | if attrs != nil { 91 | t.Fatal("Expected nil") 92 | } 93 | }) 94 | }) 95 | } 96 | 97 | type mockErrorConn struct { 98 | error error 99 | } 100 | 101 | func newMockErrConn(err error) *mockErrorConn { 102 | return &mockErrorConn{error: err} 103 | } 104 | 105 | // Begin implements driver.Conn. 106 | func (m *mockErrorConn) Begin() (driver.Tx, error) { 107 | return nil, m.error 108 | } 109 | 110 | // Close implements driver.Conn. 111 | func (m *mockErrorConn) Close() error { 112 | return m.error 113 | } 114 | 115 | // Prepare implements driver.Conn. 116 | func (m *mockErrorConn) Prepare(string) (driver.Stmt, error) { 117 | return nil, m.error 118 | } 119 | 120 | // BeginTx implements driver.ConnBeginTx. 121 | func (m *mockErrorConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { 122 | return nil, m.error 123 | } 124 | 125 | // PrepareContext implements driver.ConnPrepareContext. 126 | func (m *mockErrorConn) PrepareContext(context.Context, string) (driver.Stmt, error) { 127 | return nil, m.error 128 | } 129 | 130 | // QueryContext implements driver.QueryerContext. 131 | func (m *mockErrorConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { 132 | return nil, m.error 133 | } 134 | 135 | // ExecContext implements driver.ExecerContext. 136 | func (m *mockErrorConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) { 137 | return nil, m.error 138 | } 139 | 140 | var ( 141 | _ driver.Conn = (*mockErrorConn)(nil) 142 | _ driver.ConnBeginTx = (*mockErrorConn)(nil) 143 | _ driver.ConnPrepareContext = (*mockErrorConn)(nil) 144 | _ driver.ExecerContext = (*mockErrorConn)(nil) 145 | _ driver.QueryerContext = (*mockErrorConn)(nil) 146 | ) 147 | 148 | // var _ driver.Pinger = (*mockErrorConn)(nil) // not implemented for the test below 149 | 150 | func TestWithMockErrorConn(t *testing.T) { 151 | t.Parallel() 152 | logger := newStepLogger(slog.Default(), defaultStepLoggerOptions()) 153 | connOptions := defaultConnOptions("sqlite3", StepEventMsgWithoutEventName) 154 | w := wrapConn(newMockErrConn(errors.New("unexpected error")), logger, connOptions) 155 | t.Run("Begin", func(t *testing.T) { 156 | t.Parallel() 157 | if _, err := w.Begin(); err == nil { //nolint:staticcheck 158 | t.Fatal("Expected error") 159 | } 160 | }) 161 | t.Run("Prepare", func(t *testing.T) { 162 | t.Parallel() 163 | if _, err := w.Prepare("dummy"); err == nil { 164 | t.Fatal("Expected error") 165 | } 166 | }) 167 | t.Run("BeginTx", func(t *testing.T) { 168 | t.Parallel() 169 | if _, err := w.(driver.ConnBeginTx).BeginTx(context.Background(), driver.TxOptions{}); err == nil { 170 | t.Fatal("Expected error") 171 | } 172 | }) 173 | t.Run("PrepareContext", func(t *testing.T) { 174 | t.Parallel() 175 | if _, err := w.(driver.ConnPrepareContext).PrepareContext(context.Background(), "dummy"); err == nil { 176 | t.Fatal("Expected error") 177 | } 178 | }) 179 | } 180 | 181 | func TestPingInCase(t *testing.T) { 182 | t.Parallel() 183 | logger := newStepLogger(slog.Default(), defaultStepLoggerOptions()) 184 | conn := newMockErrConn(nil) 185 | w := &connWithContextWrapper{ 186 | connWrapper: connWrapper{ 187 | original: conn, 188 | logger: logger, 189 | options: defaultConnOptions("sqlite3", StepEventMsgWithoutEventName), 190 | }, 191 | originalConn: conn, 192 | } 193 | if err := w.Ping(context.Background()); err != nil { 194 | t.Fatal("Unexpected error") 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/testhelper/step_event_msg_options.go: -------------------------------------------------------------------------------- 1 | package testhelper 2 | 3 | import ( 4 | sqlslog "github.com/akm/sql-slog" 5 | ) 6 | 7 | var StepEventMsgOptions = []sqlslog.Option{ 8 | sqlslog.ConnBegin(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.Begin Start" }), 9 | sqlslog.ConnBegin(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.Begin Error" }), 10 | sqlslog.ConnBegin(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.Begin Complete" }), 11 | sqlslog.ConnClose(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.Close Start" }), 12 | sqlslog.ConnClose(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.Close Error" }), 13 | sqlslog.ConnClose(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.Close Complete" }), 14 | sqlslog.ConnPrepare(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.Prepare Start" }), 15 | sqlslog.ConnPrepare(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.Prepare Error" }), 16 | sqlslog.ConnPrepare(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.Prepare Complete" }), 17 | sqlslog.ConnResetSession(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.ResetSession Start" }), 18 | sqlslog.ConnResetSession(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.ResetSession Error" }), 19 | sqlslog.ConnResetSession(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.ResetSession Complete" }), 20 | sqlslog.ConnPing(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.Ping Start" }), 21 | sqlslog.ConnPing(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.Ping Error" }), 22 | sqlslog.ConnPing(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.Ping Complete" }), 23 | sqlslog.ConnExecContext(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.ExecContext Start" }), 24 | sqlslog.ConnExecContext(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.ExecContext Error" }), 25 | sqlslog.ConnExecContext(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.ExecContext Complete" }), 26 | sqlslog.ConnQueryContext(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.QueryContext Start" }), 27 | sqlslog.ConnQueryContext(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.QueryContext Error" }), 28 | sqlslog.ConnQueryContext(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.QueryContext Complete" }), 29 | sqlslog.ConnPrepareContext(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.PrepareContext Start" }), 30 | sqlslog.ConnPrepareContext(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.PrepareContext Error" }), 31 | sqlslog.ConnPrepareContext(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.PrepareContext Complete" }), 32 | sqlslog.ConnBeginTx(func(o *sqlslog.StepOptions) { o.Start.Msg = "Conn.BeginTx Start" }), 33 | sqlslog.ConnBeginTx(func(o *sqlslog.StepOptions) { o.Error.Msg = "Conn.BeginTx Error" }), 34 | sqlslog.ConnBeginTx(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Conn.BeginTx Complete" }), 35 | sqlslog.ConnectorConnect(func(o *sqlslog.StepOptions) { o.Start.Msg = "Connector.Connect Start" }), 36 | sqlslog.ConnectorConnect(func(o *sqlslog.StepOptions) { o.Error.Msg = "Connector.Connect Error" }), 37 | sqlslog.ConnectorConnect(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Connector.Connect Complete" }), 38 | sqlslog.DriverOpen(func(o *sqlslog.StepOptions) { o.Start.Msg = "Driver.Open Start" }), 39 | sqlslog.DriverOpen(func(o *sqlslog.StepOptions) { o.Error.Msg = "Driver.Open Error" }), 40 | sqlslog.DriverOpen(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Driver.Open Complete" }), 41 | sqlslog.DriverOpenConnector(func(o *sqlslog.StepOptions) { o.Start.Msg = "Driver.OpenConnector Start" }), 42 | sqlslog.DriverOpenConnector(func(o *sqlslog.StepOptions) { o.Error.Msg = "Driver.OpenConnector Error" }), 43 | sqlslog.DriverOpenConnector(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Driver.OpenConnector Complete" }), 44 | sqlslog.SqlslogOpen(func(o *sqlslog.StepOptions) { o.Start.Msg = "sqlslog.Open Start" }), 45 | sqlslog.SqlslogOpen(func(o *sqlslog.StepOptions) { o.Error.Msg = "sqlslog.Open Error" }), 46 | sqlslog.SqlslogOpen(func(o *sqlslog.StepOptions) { o.Complete.Msg = "sqlslog.Open Complete" }), 47 | sqlslog.RowsClose(func(o *sqlslog.StepOptions) { o.Start.Msg = "Rows.Close Start" }), 48 | sqlslog.RowsClose(func(o *sqlslog.StepOptions) { o.Error.Msg = "Rows.Close Error" }), 49 | sqlslog.RowsClose(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Rows.Close Complete" }), 50 | sqlslog.RowsNext(func(o *sqlslog.StepOptions) { o.Start.Msg = "Rows.Next Start" }), 51 | sqlslog.RowsNext(func(o *sqlslog.StepOptions) { o.Error.Msg = "Rows.Next Error" }), 52 | sqlslog.RowsNext(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Rows.Next Complete" }), 53 | sqlslog.RowsNextResultSet(func(o *sqlslog.StepOptions) { o.Start.Msg = "Rows.NextResultSet Start" }), 54 | sqlslog.RowsNextResultSet(func(o *sqlslog.StepOptions) { o.Error.Msg = "Rows.NextResultSet Error" }), 55 | sqlslog.RowsNextResultSet(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Rows.NextResultSet Complete" }), 56 | sqlslog.StmtClose(func(o *sqlslog.StepOptions) { o.Start.Msg = "Stmt.Close Start" }), 57 | sqlslog.StmtClose(func(o *sqlslog.StepOptions) { o.Error.Msg = "Stmt.Close Error" }), 58 | sqlslog.StmtClose(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Stmt.Close Complete" }), 59 | sqlslog.StmtExec(func(o *sqlslog.StepOptions) { o.Start.Msg = "Stmt.Exec Start" }), 60 | sqlslog.StmtExec(func(o *sqlslog.StepOptions) { o.Error.Msg = "Stmt.Exec Error" }), 61 | sqlslog.StmtExec(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Stmt.Exec Complete" }), 62 | sqlslog.StmtQuery(func(o *sqlslog.StepOptions) { o.Start.Msg = "Stmt.Query Start" }), 63 | sqlslog.StmtQuery(func(o *sqlslog.StepOptions) { o.Error.Msg = "Stmt.Query Error" }), 64 | sqlslog.StmtQuery(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Stmt.Query Complete" }), 65 | sqlslog.StmtExecContext(func(o *sqlslog.StepOptions) { o.Start.Msg = "Stmt.ExecContext Start" }), 66 | sqlslog.StmtExecContext(func(o *sqlslog.StepOptions) { o.Error.Msg = "Stmt.ExecContext Error" }), 67 | sqlslog.StmtExecContext(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Stmt.ExecContext Complete" }), 68 | sqlslog.StmtQueryContext(func(o *sqlslog.StepOptions) { o.Start.Msg = "Stmt.QueryContext Start" }), 69 | sqlslog.StmtQueryContext(func(o *sqlslog.StepOptions) { o.Error.Msg = "Stmt.QueryContext Error" }), 70 | sqlslog.StmtQueryContext(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Stmt.QueryContext Complete" }), 71 | sqlslog.TxCommit(func(o *sqlslog.StepOptions) { o.Start.Msg = "Tx.Commit Start" }), 72 | sqlslog.TxCommit(func(o *sqlslog.StepOptions) { o.Error.Msg = "Tx.Commit Error" }), 73 | sqlslog.TxCommit(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Tx.Commit Complete" }), 74 | sqlslog.TxRollback(func(o *sqlslog.StepOptions) { o.Start.Msg = "Tx.Rollback Start" }), 75 | sqlslog.TxRollback(func(o *sqlslog.StepOptions) { o.Error.Msg = "Tx.Rollback Error" }), 76 | sqlslog.TxRollback(func(o *sqlslog.StepOptions) { o.Complete.Msg = "Tx.Rollback Complete" }), 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sql-slog 2 | 3 | [![CI](https://github.com/akm/sql-slog/actions/workflows/ci.yml/badge.svg)](https://github.com/akm/sql-slog/actions/workflows/ci.yml) 4 | [![codecov](https://codecov.io/github/akm/sql-slog/graph/badge.svg?token=9BcanbSLut)](https://codecov.io/github/akm/sql-slog) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/akm/sql-slog)](https://goreportcard.com/report/github.com/akm/sql-slog) 6 | [![Go project version](https://badge.fury.io/go/github.com%2Fakm%2Fsql-slog.svg)](https://badge.fury.io/go/github.com%2Fakm%2Fsql-slog) 7 | [![Enabled Linters](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fakm%2Fsql-slog%2Frefs%2Fheads%2Fmain%2F.project.yaml&query=%24.linters&label=enabled%20linters&color=%2317AFC2)](.golangci.yml) 8 | [![Documentation](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/akm/sql-slog) 9 | [![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/akm/sql-slog)](./go.mod) 10 | [![license](https://img.shields.io/github/license/akm/sql-slog)](./LICENSE) 11 | 12 | A logger for Go SQL database drivers with [log/slog](https://pkg.go.dev/log/slog) without modifying existing `*sql.DB` stdlib usage. 13 | 14 | ## FEATURES 15 | 16 | - [x] Keep using (or re-use existing) `*sql.DB` as is. 17 | - [x] No logger adapters. Just use [log/slog](https://pkg.go.dev/log/slog) 18 | - [x] No dependencies 19 | - [x] Leveled, detailed, and configurable logging. 20 | - [x] Duration tracking 21 | - [x] Trackable log output 22 | - [x] 100% test coverage 23 | 24 | See [godoc](https://pkg.go.dev/github.com/akm/sql-slog) for more details. 25 | 26 | ## LOG EXAMPLES 27 | 28 | - [sqlite3](./examples/logs-sqlite3/results) 29 | - [postgres](./examples/logs-postgres/results) 30 | - [mysql](./examples/logs-mysql/results) 31 | 32 | ## INSTALL 33 | 34 | To install sql-slog, use the following command: 35 | 36 | ```sh 37 | go get -u github.com/akm/sql-slog 38 | ``` 39 | 40 | ## USAGE 41 | 42 | To use sql-slog, you can open a database connection with logging enabled as follows: 43 | 44 | ```golang 45 | db, logger, err := sqlslog.Open(ctx, "mysql", dsn) 46 | ``` 47 | 48 | This is the easiest way to use sqlslog. It's similar to the usage of `Open` from `database/sql` like this: 49 | 50 | ```golang 51 | db, err := sql.Open("mysql", dsn) 52 | ``` 53 | 54 | The differences are: 55 | 56 | 1. Pass `context.Context` as the first argument. 57 | 2. `*slog.Logger` is returned as the second argument. 58 | 3. `sqlslog.Open` can take a lot of [Option](https://pkg.go.dev/github.com/akm/sql-slog#Option) s. 59 | 60 | See [godoc examples](https://pkg.go.dev/github.com/akm/sql-slog#example-Open) for more details. 61 | 62 | ## EXAMPLES 63 | 64 | ### [examples/with-sqlc](./examples/with-sqlc/) 65 | 66 | An example showing how sql-slog works with [sqlc](https://sqlc.dev/). 67 | This example is almost same as [Getting started with SQLite](https://docs.sqlc.dev/en/latest/tutorials/getting-started-sqlite.html) but uses [sqlslog.Open](https://pkg.go.dev/github.com/akm/sql-slog#Open) instead of [sql.Open](https://pkg.go.dev/database/sql#Open). 68 | 69 |
Stdout with sqlslog package 70 | 71 | ``` 72 | $ make -C examples/with-sqlc run 73 | go build ./... 74 | go run . 75 | time=2025-03-19T21:23:36.992+09:00 level=INFO msg=Open driver=sqlite dsn=:memory: duration=22083 76 | time=2025-03-19T21:23:36.992+09:00 level=INFO msg=Driver.Open dsn=:memory: duration=274042 conn_id=_hMZDi7TQfEgBKN_ 77 | time=2025-03-19T21:23:36.992+09:00 level=INFO msg=Connector.Connect duration=294292 78 | time=2025-03-19T21:23:36.993+09:00 level=INFO msg=Conn.ExecContext conn_id=_hMZDi7TQfEgBKN_ query="CREATE TABLE authors (\n id INTEGER PRIMARY KEY,\n name text NOT NULL,\n bio text\n);\n" args=[] duration=537125 79 | time=2025-03-19T21:23:36.993+09:00 level=INFO msg=Conn.QueryContext conn_id=_hMZDi7TQfEgBKN_ query="-- name: ListAuthors :many\nSELECT id, name, bio FROM authors\nORDER BY name\n" args=[] duration=23250 80 | 2025/03/19 21:23:36 [] 81 | time=2025-03-19T21:23:36.993+09:00 level=INFO msg=Conn.QueryContext conn_id=_hMZDi7TQfEgBKN_ query="-- name: CreateAuthor :one\nINSERT INTO authors (\n name, bio\n) VALUES (\n ?, ?\n)\nRETURNING id, name, bio\n" args="[{Name: Ordinal:1 Value:Brian Kernighan} {Name: Ordinal:2 Value:Co-author of The C Programming Language and The Go Programming Language}]" duration=20375 82 | 2025/03/19 21:23:36 {1 Brian Kernighan {Co-author of The C Programming Language and The Go Programming Language true}} 83 | time=2025-03-19T21:23:36.993+09:00 level=INFO msg=Conn.QueryContext conn_id=_hMZDi7TQfEgBKN_ query="-- name: GetAuthor :one\nSELECT id, name, bio FROM authors\nWHERE id = ? LIMIT 1\n" args="[{Name: Ordinal:1 Value:1}]" duration=8083 84 | 2025/03/19 21:23:36 true 85 | ``` 86 | 87 |
88 | 89 |
Stdout without sqlslog package 90 | 91 | ``` 92 | $ SKIP_SQLSLOG=1 make -C examples/with-sqlc run 93 | go build ./... 94 | go run . 95 | 2025/03/19 21:23:19 [] 96 | 2025/03/19 21:23:19 {1 Brian Kernighan {Co-author of The C Programming Language and The Go Programming Language true}} 97 | 2025/03/19 21:23:19 true 98 | ``` 99 | 100 |
101 | 102 | ### [examples/with-go-requestid](./examples/with-go-requestid/) 103 | 104 | An example showing how sql-slog works with [go-requestid](https://github.com/akm/go-requestid). 105 | You can see DB query logs with request IDs in the same log like the following: 106 | 107 | > time=2025-02-27T23:53:48.982+09:00 level=DEBUG msg=Conn.QueryContext conn_id=L1snTUaknlmsin8b query="SELECT id, title, status FROM todos" args=[] req_id=0JKGwDLjw77BjBnf 108 | 109 | `conn_id` is a tracking ID for DB connection by sql-slog, and `req_id` is a tracking ID for HTTP request by go-requestid. 110 | 111 | See [server-logs.txt](./examples/with-go-requestid/server-logs.txt) and [main.go](./examples/with-go-requestid/main.go) for more details. 112 | 113 | ## TEST 114 | 115 | - [For MySQL](https://github.com/akm/sql-slog/blob/3f72cc68aefa9ac05b031d865dbdaec8a361c2c9/tests/mysql/low_level_with_context_test.go) for more details. 116 | - [For PostgreSQL](https://github.com/akm/sql-slog/blob/3f72cc68aefa9ac05b031d865dbdaec8a361c2c9/tests/postgres/low_level_with_context_test.go) for more details. 117 | - [For SQLite3](https://github.com/akm/sql-slog/blob/3f72cc68aefa9ac05b031d865dbdaec8a361c2c9/tests/sqlite3/low_level_without_context_test.go) for more details. 118 | 119 | ## MOTIVATION 120 | 121 | I want to: 122 | 123 | - Keep using `*sql.DB`. 124 | - To work with thin ORM 125 | - Use log/slog 126 | - Leverage structured logging 127 | - Fetch and log `context.Context` values if needed 128 | 129 | ## REFERENCES 130 | 131 | - [Stdlib sql.DB](https://github.com/golang/go/blob/master/src/database/sql/sql.go) 132 | - [SQL driver interfaces](https://github.com/golang/go/blob/master/src/database/sql/driver/driver.go) 133 | - [SQL driver implementation](https://go.dev/wiki/SQLDrivers) 134 | - [log/slog](https://pkg.go.dev/log/slog) 135 | - [Structured Logging with slog](https://go.dev/blog/slog) 136 | 137 | ## CONTRIBUTING 138 | 139 | If you find a bug, typo, incorrect test, have an idea, or want to help with an existing issue, please create an issue or pull request. 140 | 141 | ## INSPIRED BY 142 | 143 | - [github.com/simukti/sqldb-logger](https://github.com/simukti/sqldb-logger). 144 | 145 | ## LICENSE 146 | 147 | [MIT](./LICENSE) 148 | --------------------------------------------------------------------------------