Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions frankenphp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
75 changes: 75 additions & 0 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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 {

Copy link

Choose a reason for hiding this comment

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

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
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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
}
ctx := phpThreads[threadIndex].context()
if globalLogger.Enabled(ctx, lvl) {
globalLogger.LogAttrs(ctx, slog.Level(level), GoString(unsafe.Pointer(m)), mapToAttr(attrs)...)
}
return nil
}

Copy link

Choose a reason for hiding this comment

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


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)
Expand Down
6 changes: 6 additions & 0 deletions frankenphp.stub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> $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 {}
10 changes: 8 additions & 2 deletions frankenphp_arginfo.h
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -35,14 +35,19 @@ 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);
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)
Expand All @@ -55,5 +60,6 @@ static const zend_function_entry ext_functions[] = {
ZEND_FE(frankenphp_response_headers, arginfo_frankenphp_response_headers)
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
};
29 changes: 28 additions & 1 deletion frankenphp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
23 changes: 23 additions & 0 deletions testdata/log_to_slog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

// NOTE: use CGO frankenphp_log method.
// The message and it's optional arguments are expected to be logged by go' std slog system.
// The log level should be respected out of the box by the std' slog.
//
// ac[0] expect the log message as string
// ac[1] expect the slog.Level, from -8 to +8
// ac[2] is an optional php map, which will be converted to a []slog.Attr

frankenphp_log("some debug message", -4, [
"key int" => 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"],
]);
Loading