From 0f0d1820e6059aad4e191a4dff00bc7c761154ad Mon Sep 17 00:00:00 2001 From: Raphael Coeffic Date: Sun, 16 Nov 2025 11:27:33 +0100 Subject: [PATCH 1/5] feat: add frankenphp_log_attrs() as a PHP function The CGO method allow to log a php message while binding an array of random type as slog.Attr. --- frankenphp.c | 22 ++++++++++++ frankenphp.go | 75 ++++++++++++++++++++++++++++++++++++++++ frankenphp.stub.php | 6 ++++ frankenphp_arginfo.h | 18 ++++++---- frankenphp_test.go | 29 +++++++++++++++- testdata/log_to_slog.php | 23 ++++++++++++ 6 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 testdata/log_to_slog.php diff --git a/frankenphp.c b/frankenphp.c index 5e070cd36..58664924c 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -550,6 +550,28 @@ PHP_FUNCTION(mercure_publish) { RETURN_THROWS(); } +PHP_FUNCTION(frankenphp_log) { + char *message = NULL; + size_t message_len = 0; + zend_long level = 0; + zval *context = NULL; + + ZEND_PARSE_PARAMETERS_START(2, 3) + Z_PARAM_STRING(message, message_len) + Z_PARAM_LONG(level) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY(context) + ZEND_PARSE_PARAMETERS_END(); + + char * ret = NULL; + ret = go_log_attrs(thread_index, message, message_len, (int)level, context); + if (ret != NULL) { + zend_throw_exception(spl_ce_RuntimeException, ret, 0); + // free(ret); // NOTE: is the string copied by zend_throw ?? + RETURN_THROWS(); + } +} + PHP_MINIT_FUNCTION(frankenphp) { zend_function *func; diff --git a/frankenphp.go b/frankenphp.go index 4b5c0972b..9efdb2b4c 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -682,6 +682,81 @@ func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) { } } +// go_log_attrs is a cgo-exported bridge between PHP and the Go slog logger. +// +// It is called from C/PHP and must not panic. All errors are reported by +// returning a C-allocated error string; on success it returns NULL. +// +// Parameters: +// +// threadIndex: +// - Index into the phpThreads table, used to retrieve the Go context for +// the current PHP request/thread. +// +// message: +// - Pointer to a C string containing the log message bytes. The memory +// is owned by the caller and must NOT be freed by Go. +// +// len: +// - Length of the message, in bytes, as seen from C (not including the +// terminating NUL). This is passed to C.GoStringN to build the Go string. +// +// level: +// - Numeric log level compatible with slog.Level values. It is cast to +// slog.Level inside this function. +// +// cattrs: +// - Pointer to a PHP zval representing an associative array of attributes, +// or NULL. When non-NULL, it is converted to map[string]any via GoMap[any] +// and then mapped to slog.Attr values (using slog.Any under the hood). +// +// Return value: +// +// On success: +// - Returns NULL and the message is logged (if the logger is enabled at +// the given level). +// +// On error: +// - Returns a non-NULL *C.char pointing to a NUL-terminated error message +// allocated with C.CString. The caller is responsible for releasing +// this memory. +// +//export go_log_attrs +func go_log_attrs(threadIndex C.uintptr_t, message *C.char, len C.int, level C.int, cattrs *C.zval) *C.char { + var attrs map[string]any + + if cattrs == nil { + attrs = nil + } else { + var err error + if attrs, err = GoMap[any](unsafe.Pointer(cattrs)); err != nil { + // NOTE: return value is already formatted for a PHP exception message. + return C.CString("Failed to log message: converting attrs: " + err.Error()) + } + } + + m := C.GoStringN(message, len) + lvl := slog.Level(level) + + ctx := phpThreads[threadIndex].context() + + if globalLogger.Enabled(ctx, lvl) { + globalLogger.LogAttrs(ctx, lvl, m, mapToAttr(attrs)...) + } + + return nil +} + +func mapToAttr(input map[string]any) []slog.Attr { + out := make([]slog.Attr, 0, len(input)) + + for key, val := range input { + out = append(out, slog.Any(key, val)) + } + + return out +} + //export go_is_context_done func go_is_context_done(threadIndex C.uintptr_t) C.bool { return C.bool(phpThreads[threadIndex].frankenPHPContext().isDone) diff --git a/frankenphp.stub.php b/frankenphp.stub.php index 60ac5d588..9b19160f7 100644 --- a/frankenphp.stub.php +++ b/frankenphp.stub.php @@ -36,3 +36,9 @@ function apache_response_headers(): array|bool {} * @param string|string[] $topics */ function mercure_publish(string|array $topics, string $data = '', bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null): string {} + +/** + * @param int $level The importance or severity of a log event. The higher the level, the more important or severe the event. Common levels are -4 for debug, 0 for info, 4 for warn, and 8 for error. For more details, see: https://pkg.go.dev/log/slog#Level + * array $context Values of the array will be converted to the corresponding Go type (if supported by FrankenPHP) and added to the context of the structured logs using https://pkg.go.dev/log/slog#Attr + */ +function frankenphp_log(string $message, int $level = 0, array $context = []): void {} diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 558c6e3cf..24b4f5152 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: cd534a8394f535a600bf45a333955d23b5154756 */ + * Stub hash: 28aa97e2c6102b3e51059dbd001ac65679f0bfda */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) @@ -35,6 +35,11 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_mercure_publish, 0, 1, IS_STRING ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, retry, IS_LONG, 1, "null") ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_log, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, message, IS_STRING, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, level, IS_LONG, 0, "0") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, context, IS_ARRAY, 0, "[]") +ZEND_END_ARG_INFO() ZEND_FUNCTION(frankenphp_handle_request); ZEND_FUNCTION(headers_send); @@ -42,18 +47,19 @@ ZEND_FUNCTION(frankenphp_finish_request); ZEND_FUNCTION(frankenphp_request_headers); ZEND_FUNCTION(frankenphp_response_headers); ZEND_FUNCTION(mercure_publish); - +ZEND_FUNCTION(frankenphp_log); static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) ZEND_FE(headers_send, arginfo_headers_send) ZEND_FE(frankenphp_finish_request, arginfo_frankenphp_finish_request) - ZEND_FALIAS(fastcgi_finish_request, frankenphp_finish_request, arginfo_fastcgi_finish_request) + ZEND_RAW_FENTRY("fastcgi_finish_request", zif_frankenphp_finish_request, arginfo_fastcgi_finish_request, 0, NULL, NULL) ZEND_FE(frankenphp_request_headers, arginfo_frankenphp_request_headers) - ZEND_FALIAS(apache_request_headers, frankenphp_request_headers, arginfo_apache_request_headers) - ZEND_FALIAS(getallheaders, frankenphp_request_headers, arginfo_getallheaders) + ZEND_RAW_FENTRY("apache_request_headers", zif_frankenphp_request_headers, arginfo_apache_request_headers, 0, NULL, NULL) + ZEND_RAW_FENTRY("getallheaders", zif_frankenphp_request_headers, arginfo_getallheaders, 0, NULL, NULL) ZEND_FE(frankenphp_response_headers, arginfo_frankenphp_response_headers) - ZEND_FALIAS(apache_response_headers, frankenphp_response_headers, arginfo_apache_response_headers) + ZEND_RAW_FENTRY("apache_response_headers", zif_frankenphp_response_headers, arginfo_apache_response_headers, 0, NULL, NULL) ZEND_FE(mercure_publish, arginfo_mercure_publish) + ZEND_FE(frankenphp_log, arginfo_frankenphp_log) ZEND_FE_END }; diff --git a/frankenphp_test.go b/frankenphp_test.go index 713797403..7e5204a2b 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -1013,7 +1013,34 @@ func FuzzRequest(f *testing.F) { // Headers should always be present even if empty assert.Contains(t, body, fmt.Sprintf("[CONTENT_TYPE] => %s", fuzzedString)) assert.Contains(t, body, fmt.Sprintf("[HTTP_FUZZED] => %s", fuzzedString)) - }, &testOptions{workerScript: "request-headers.php"}) }) } + +func TestFrankenPHPLog(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, _ := testGet("http://example.com/log_to_slog.php", handler, t) + assert.Empty(t, body) + }, &testOptions{ + logger: logger, + nbParallelRequests: 1, + nbWorkers: 1, + }) + + logOutput := buf.String() + + t.Logf("captured log output: %s", logOutput) + + for level, needle := range map[string]string{ + "debug attrs": `level=DEBUG msg="some debug message" "key int"=1`, + "info attrs": `level=INFO msg="some info message" "key string"=string`, + "warn attrs": `level=WARN msg="some warn message"`, + "error attrs": `level=ERROR msg="some error message" err="[a v]"`, + } { + assert.Containsf(t, logOutput, needle, "should contains %q log", level) + } +} diff --git a/testdata/log_to_slog.php b/testdata/log_to_slog.php new file mode 100644 index 000000000..b8e831229 --- /dev/null +++ b/testdata/log_to_slog.php @@ -0,0 +1,23 @@ + 1, +]); + +frankenphp_log("some info message", 0, [ + "key string" => "string", +]); + +frankenphp_log("some warn message", 4); + +frankenphp_log("some error message", 8, [ + "err" => ["a", "v"], +]); From ee4dbbb85942d160ce0e5ef7299d95609511596a Mon Sep 17 00:00:00 2001 From: Raphael Coeffic Date: Wed, 19 Nov 2025 22:48:14 +0100 Subject: [PATCH 2/5] fix: do not touch alias entries --- frankenphp_arginfo.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index 24b4f5152..6417cfb70 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -53,12 +53,12 @@ static const zend_function_entry ext_functions[] = { ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) ZEND_FE(headers_send, arginfo_headers_send) ZEND_FE(frankenphp_finish_request, arginfo_frankenphp_finish_request) - ZEND_RAW_FENTRY("fastcgi_finish_request", zif_frankenphp_finish_request, arginfo_fastcgi_finish_request, 0, NULL, NULL) + ZEND_FALIAS(fastcgi_finish_request, frankenphp_finish_request, arginfo_fastcgi_finish_request) ZEND_FE(frankenphp_request_headers, arginfo_frankenphp_request_headers) - ZEND_RAW_FENTRY("apache_request_headers", zif_frankenphp_request_headers, arginfo_apache_request_headers, 0, NULL, NULL) - ZEND_RAW_FENTRY("getallheaders", zif_frankenphp_request_headers, arginfo_getallheaders, 0, NULL, NULL) + ZEND_FALIAS(apache_request_headers, frankenphp_request_headers, arginfo_apache_request_headers) + ZEND_FALIAS(getallheaders, frankenphp_request_headers, arginfo_getallheaders) ZEND_FE(frankenphp_response_headers, arginfo_frankenphp_response_headers) - ZEND_RAW_FENTRY("apache_response_headers", zif_frankenphp_response_headers, arginfo_apache_response_headers, 0, NULL, NULL) + ZEND_FALIAS(apache_response_headers, frankenphp_response_headers, arginfo_apache_response_headers) ZEND_FE(mercure_publish, arginfo_mercure_publish) ZEND_FE(frankenphp_log, arginfo_frankenphp_log) ZEND_FE_END From ee7d418ed85ff3eec389071065e12526cc319155 Mon Sep 17 00:00:00 2001 From: Quentin Burgess Date: Sun, 23 Nov 2025 10:59:04 +0100 Subject: [PATCH 3/5] misc(logging): clean up --- frankenphp.c | 2 +- frankenphp.go | 39 --------------------------------------- 2 files changed, 1 insertion(+), 40 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 58664924c..00692189b 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -567,7 +567,7 @@ PHP_FUNCTION(frankenphp_log) { ret = go_log_attrs(thread_index, message, message_len, (int)level, context); if (ret != NULL) { zend_throw_exception(spl_ce_RuntimeException, ret, 0); - // free(ret); // NOTE: is the string copied by zend_throw ?? + free(ret); RETURN_THROWS(); } } diff --git a/frankenphp.go b/frankenphp.go index 9efdb2b4c..0b04514da 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -682,45 +682,6 @@ func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) { } } -// go_log_attrs is a cgo-exported bridge between PHP and the Go slog logger. -// -// It is called from C/PHP and must not panic. All errors are reported by -// returning a C-allocated error string; on success it returns NULL. -// -// Parameters: -// -// threadIndex: -// - Index into the phpThreads table, used to retrieve the Go context for -// the current PHP request/thread. -// -// message: -// - Pointer to a C string containing the log message bytes. The memory -// is owned by the caller and must NOT be freed by Go. -// -// len: -// - Length of the message, in bytes, as seen from C (not including the -// terminating NUL). This is passed to C.GoStringN to build the Go string. -// -// level: -// - Numeric log level compatible with slog.Level values. It is cast to -// slog.Level inside this function. -// -// cattrs: -// - Pointer to a PHP zval representing an associative array of attributes, -// or NULL. When non-NULL, it is converted to map[string]any via GoMap[any] -// and then mapped to slog.Attr values (using slog.Any under the hood). -// -// Return value: -// -// On success: -// - Returns NULL and the message is logged (if the logger is enabled at -// the given level). -// -// On error: -// - Returns a non-NULL *C.char pointing to a NUL-terminated error message -// allocated with C.CString. The caller is responsible for releasing -// this memory. -// //export go_log_attrs func go_log_attrs(threadIndex C.uintptr_t, message *C.char, len C.int, level C.int, cattrs *C.zval) *C.char { var attrs map[string]any From ba25c2c779464f432f16d1f0b24f0d6fca059698 Mon Sep 17 00:00:00 2001 From: Quentin Burgess Date: Sun, 23 Nov 2025 11:05:23 +0100 Subject: [PATCH 4/5] misc(c): apply clang-format --- frankenphp.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 00692189b..20df19f29 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -557,13 +557,13 @@ PHP_FUNCTION(frankenphp_log) { zval *context = NULL; ZEND_PARSE_PARAMETERS_START(2, 3) - Z_PARAM_STRING(message, message_len) - Z_PARAM_LONG(level) - Z_PARAM_OPTIONAL - Z_PARAM_ARRAY(context) + Z_PARAM_STRING(message, message_len) + Z_PARAM_LONG(level) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY(context) ZEND_PARSE_PARAMETERS_END(); - char * ret = NULL; + char *ret = NULL; ret = go_log_attrs(thread_index, message, message_len, (int)level, context); if (ret != NULL) { zend_throw_exception(spl_ce_RuntimeException, ret, 0); From 68345ecdd69209dec73e6a28a02078a634fc7203 Mon Sep 17 00:00:00 2001 From: Quentin Burgess Date: Mon, 24 Nov 2025 09:30:31 +0100 Subject: [PATCH 5/5] refactor(frankenlog): rework func params type Currently, franken.gp:705 won't compile --- frankenphp.c | 7 +++---- frankenphp.go | 12 ++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 20df19f29..f7528816b 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -551,20 +551,19 @@ PHP_FUNCTION(mercure_publish) { } PHP_FUNCTION(frankenphp_log) { - char *message = NULL; - size_t message_len = 0; + zend_string *message = NULL; zend_long level = 0; zval *context = NULL; ZEND_PARSE_PARAMETERS_START(2, 3) - Z_PARAM_STRING(message, message_len) + Z_PARAM_STR(message) Z_PARAM_LONG(level) Z_PARAM_OPTIONAL Z_PARAM_ARRAY(context) ZEND_PARSE_PARAMETERS_END(); char *ret = NULL; - ret = go_log_attrs(thread_index, message, message_len, (int)level, context); + ret = go_log_attrs(thread_index, message, level, context); if (ret != NULL) { zend_throw_exception(spl_ce_RuntimeException, ret, 0); free(ret); diff --git a/frankenphp.go b/frankenphp.go index 0b04514da..72105875d 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -683,7 +683,7 @@ func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) { } //export go_log_attrs -func go_log_attrs(threadIndex C.uintptr_t, message *C.char, len C.int, level C.int, cattrs *C.zval) *C.char { +func go_log_attrs(threadIndex C.uintptr_t, message *C.zend_string, level C.zend_long, cattrs *C.zval) *C.char { var attrs map[string]any if cattrs == nil { @@ -696,13 +696,13 @@ func go_log_attrs(threadIndex C.uintptr_t, message *C.char, len C.int, level C.i } } - m := C.GoStringN(message, len) - lvl := slog.Level(level) - ctx := phpThreads[threadIndex].context() - if globalLogger.Enabled(ctx, lvl) { - globalLogger.LogAttrs(ctx, lvl, m, mapToAttr(attrs)...) + if globalLogger.Enabled(ctx, slog.Level(level)) { + globalLogger.LogAttrs(ctx, + slog.Level(level), + GoString(unsafe.Pointer(message)), + mapToAttr(attrs)...) } return nil