Skip to content

Commit 91c553f

Browse files
raphaelcoefficburgesQdunglas
authored
feat: add support for structured logging with the frankenphp_log() PHP function (#1979)
As discussed in #1961, there is no real way to pass a severity/level to any log handler offered by PHP that would make it to the FrankenPHP layer. This new function allows applications embedding FrankenPHP to integrate PHP logging into the application itself, thus offering a more streamlined experience. --------- Co-authored-by: Quentin Burgess <[email protected]> Co-authored-by: Kévin Dunglas <[email protected]>
1 parent 7fca07e commit 91c553f

16 files changed

+211
-67
lines changed

frankenphp.c

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,30 @@ PHP_FUNCTION(mercure_publish) {
549549
RETURN_THROWS();
550550
}
551551

552+
PHP_FUNCTION(frankenphp_log) {
553+
zend_string *message = NULL;
554+
zend_long level = 0;
555+
zval *context = NULL;
556+
557+
ZEND_PARSE_PARAMETERS_START(2, 3)
558+
Z_PARAM_STR(message)
559+
Z_PARAM_LONG(level)
560+
Z_PARAM_OPTIONAL
561+
Z_PARAM_ARRAY(context)
562+
ZEND_PARSE_PARAMETERS_END();
563+
564+
char *ret = NULL;
565+
ret = go_log_attrs(thread_index, message, level, context);
566+
if (ret != NULL) {
567+
zend_throw_exception(spl_ce_RuntimeException, ret, 0);
568+
free(ret);
569+
RETURN_THROWS();
570+
}
571+
}
572+
552573
PHP_MINIT_FUNCTION(frankenphp) {
574+
register_frankenphp_symbols(module_number);
575+
553576
zend_function *func;
554577

555578
// Override putenv

frankenphp.go

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@ func go_read_cookies(threadIndex C.uintptr_t) *C.char {
663663
//export go_log
664664
func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) {
665665
ctx := phpThreads[threadIndex].context()
666+
logger := phpThreads[threadIndex].frankenPHPContext().logger
666667

667668
m := C.GoString(message)
668669
le := syslogLevelInfo
@@ -673,24 +674,61 @@ func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) {
673674

674675
switch le {
675676
case syslogLevelEmerg, syslogLevelAlert, syslogLevelCrit, syslogLevelErr:
676-
if globalLogger.Enabled(ctx, slog.LevelError) {
677-
globalLogger.LogAttrs(ctx, slog.LevelError, m, slog.String("syslog_level", syslogLevel(level).String()))
677+
if logger.Enabled(ctx, slog.LevelError) {
678+
logger.LogAttrs(ctx, slog.LevelError, m, slog.String("syslog_level", le.String()))
678679
}
679680

680681
case syslogLevelWarn:
681-
if globalLogger.Enabled(ctx, slog.LevelWarn) {
682-
globalLogger.LogAttrs(ctx, slog.LevelWarn, m, slog.String("syslog_level", syslogLevel(level).String()))
682+
if logger.Enabled(ctx, slog.LevelWarn) {
683+
logger.LogAttrs(ctx, slog.LevelWarn, m, slog.String("syslog_level", le.String()))
683684
}
685+
684686
case syslogLevelDebug:
685-
if globalLogger.Enabled(ctx, slog.LevelDebug) {
686-
globalLogger.LogAttrs(ctx, slog.LevelDebug, m, slog.String("syslog_level", syslogLevel(level).String()))
687+
if logger.Enabled(ctx, slog.LevelDebug) {
688+
logger.LogAttrs(ctx, slog.LevelDebug, m, slog.String("syslog_level", le.String()))
687689
}
688690

689691
default:
690-
if globalLogger.Enabled(ctx, slog.LevelInfo) {
691-
globalLogger.LogAttrs(ctx, slog.LevelInfo, m, slog.String("syslog_level", syslogLevel(level).String()))
692+
if logger.Enabled(ctx, slog.LevelInfo) {
693+
logger.LogAttrs(ctx, slog.LevelInfo, m, slog.String("syslog_level", le.String()))
694+
}
695+
}
696+
}
697+
698+
//export go_log_attrs
699+
func go_log_attrs(threadIndex C.uintptr_t, message *C.zend_string, cLevel C.zend_long, cAttrs *C.zval) *C.char {
700+
ctx := phpThreads[threadIndex].context()
701+
logger := phpThreads[threadIndex].frankenPHPContext().logger
702+
703+
level := slog.Level(cLevel)
704+
705+
if !logger.Enabled(ctx, level) {
706+
return nil
707+
}
708+
709+
var attrs map[string]any
710+
711+
if cAttrs != nil {
712+
var err error
713+
if attrs, err = GoMap[any](unsafe.Pointer(*(**C.zend_array)(unsafe.Pointer(&cAttrs.value[0])))); err != nil {
714+
// PHP exception message.
715+
return C.CString("Failed to log message: converting attrs: " + err.Error())
692716
}
693717
}
718+
719+
logger.LogAttrs(ctx, level, GoString(unsafe.Pointer(message)), mapToAttr(attrs)...)
720+
721+
return nil
722+
}
723+
724+
func mapToAttr(input map[string]any) []slog.Attr {
725+
out := make([]slog.Attr, 0, len(input))
726+
727+
for key, val := range input {
728+
out = append(out, slog.Any(key, val))
729+
}
730+
731+
return out
694732
}
695733

696734
//export go_is_context_done

frankenphp.stub.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
/** @generate-class-entries */
44

5+
/** @var int */
6+
const FRANKENPHP_LOG_LEVEL_DEBUG = -4;
7+
8+
/** @var int */
9+
const FRANKENPHP_LOG_LEVEL_INFO = 0;
10+
11+
/** @var int */
12+
const FRANKENPHP_LOG_LEVEL_WARN = 4;
13+
14+
/** @var int */
15+
const FRANKENPHP_LOG_LEVEL_ERROR = 8;
16+
517
function frankenphp_handle_request(callable $callback): bool {}
618

719
function headers_send(int $status = 200): int {}
@@ -36,3 +48,9 @@ function apache_response_headers(): array|bool {}
3648
* @param string|string[] $topics
3749
*/
3850
function mercure_publish(string|array $topics, string $data = '', bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null): string {}
51+
52+
/**
53+
* @param int $level The importance or severity of a log event. The higher the level, the more important or severe the event. For more details, see: https://pkg.go.dev/log/slog#Level
54+
* 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
55+
*/
56+
function frankenphp_log(string $message, int $level = 0, array $context = []): void {}

frankenphp_arginfo.h

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* This is a generated file, edit the .stub.php file instead.
2-
* Stub hash: cd534a8394f535a600bf45a333955d23b5154756 */
2+
* Stub hash: 60f0d27c04f94d7b24c052e91ef294595a2bc421 */
33

44
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, _IS_BOOL, 0)
55
ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0)
@@ -35,13 +35,20 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_mercure_publish, 0, 1, IS_STRING
3535
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, retry, IS_LONG, 1, "null")
3636
ZEND_END_ARG_INFO()
3737

38+
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_log, 0, 1, IS_VOID, 0)
39+
ZEND_ARG_TYPE_INFO(0, message, IS_STRING, 0)
40+
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, level, IS_LONG, 0, "0")
41+
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, context, IS_ARRAY, 0, "[]")
42+
ZEND_END_ARG_INFO()
43+
3844

3945
ZEND_FUNCTION(frankenphp_handle_request);
4046
ZEND_FUNCTION(headers_send);
4147
ZEND_FUNCTION(frankenphp_finish_request);
4248
ZEND_FUNCTION(frankenphp_request_headers);
4349
ZEND_FUNCTION(frankenphp_response_headers);
4450
ZEND_FUNCTION(mercure_publish);
51+
ZEND_FUNCTION(frankenphp_log);
4552

4653

4754
static const zend_function_entry ext_functions[] = {
@@ -55,5 +62,14 @@ static const zend_function_entry ext_functions[] = {
5562
ZEND_FE(frankenphp_response_headers, arginfo_frankenphp_response_headers)
5663
ZEND_FALIAS(apache_response_headers, frankenphp_response_headers, arginfo_apache_response_headers)
5764
ZEND_FE(mercure_publish, arginfo_mercure_publish)
65+
ZEND_FE(frankenphp_log, arginfo_frankenphp_log)
5866
ZEND_FE_END
5967
};
68+
69+
static void register_frankenphp_symbols(int module_number)
70+
{
71+
REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_DEBUG", -4, CONST_PERSISTENT);
72+
REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_INFO", 0, CONST_PERSISTENT);
73+
REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_WARN", 4, CONST_PERSISTENT);
74+
REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_ERROR", 8, CONST_PERSISTENT);
75+
}

frankenphp_test.go

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ import (
3232
"github.com/dunglas/frankenphp/internal/fastabs"
3333
"github.com/stretchr/testify/assert"
3434
"github.com/stretchr/testify/require"
35-
"go.uber.org/zap/exp/zapslog"
36-
"go.uber.org/zap/zapcore"
37-
"go.uber.org/zap/zaptest"
38-
"go.uber.org/zap/zaptest/observer"
3935
)
4036

4137
type testOptions struct {
@@ -62,10 +58,6 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *
6258
cwd, _ := os.Getwd()
6359
testDataDir := cwd + "/testdata/"
6460

65-
if opts.logger == nil {
66-
opts.logger = slog.New(zapslog.NewHandler(zaptest.NewLogger(t).Core()))
67-
}
68-
6961
initOpts := []frankenphp.Option{frankenphp.WithLogger(opts.logger)}
7062
if opts.workerScript != "" {
7163
workerOpts := []frankenphp.WorkerOption{
@@ -423,44 +415,68 @@ my_autoloader`, i), body)
423415
}, opts)
424416
}
425417

426-
func TestLog_module(t *testing.T) { testLog(t, &testOptions{}) }
427-
func TestLog_worker(t *testing.T) {
428-
testLog(t, &testOptions{workerScript: "log.php"})
418+
func TestLog_error_log_module(t *testing.T) { testLog_error_log(t, &testOptions{}) }
419+
func TestLog_error_log_worker(t *testing.T) {
420+
testLog_error_log(t, &testOptions{workerScript: "log-error_log.php"})
421+
}
422+
func testLog_error_log(t *testing.T, opts *testOptions) {
423+
var buf fmt.Stringer
424+
opts.logger, buf = newTestLogger(t)
425+
426+
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
427+
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/log-error_log.php?i=%d", i), nil)
428+
w := httptest.NewRecorder()
429+
handler(w, req)
430+
431+
assert.Contains(t, buf.String(), fmt.Sprintf("request %d", i))
432+
}, opts)
433+
}
434+
435+
func TestLog_frankenphp_log_module(t *testing.T) { testLog_frankenphp_log(t, &testOptions{}) }
436+
func TestLog_frankenphp_log_worker(t *testing.T) {
437+
testLog_frankenphp_log(t, &testOptions{workerScript: "log-frankenphp_log.php"})
429438
}
430-
func testLog(t *testing.T, opts *testOptions) {
431-
logger, logs := observer.New(zapcore.InfoLevel)
432-
opts.logger = slog.New(zapslog.NewHandler(logger))
439+
func testLog_frankenphp_log(t *testing.T, opts *testOptions) {
440+
var buf fmt.Stringer
441+
opts.logger, buf = newTestLogger(t)
433442

434443
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
435-
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/log.php?i=%d", i), nil)
444+
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/log-frankenphp_log.php?i=%d", i), nil)
436445
w := httptest.NewRecorder()
437446
handler(w, req)
438447

439-
for logs.FilterMessage(fmt.Sprintf("request %d", i)).Len() <= 0 {
448+
logs := buf.String()
449+
for _, message := range []string{
450+
fmt.Sprintf(`level=DEBUG msg="some debug message %d" "key int"=1`, i),
451+
fmt.Sprintf(`level=INFO msg="some info message %d" "key string"=string`, i),
452+
fmt.Sprintf(`level=WARN msg="some warn message %d"`, i),
453+
fmt.Sprintf(`level=ERROR msg="some error message %d" err="[a v]"`, i),
454+
} {
455+
assert.Contains(t, logs, message)
440456
}
441457
}, opts)
442458
}
443459

444460
func TestConnectionAbort_module(t *testing.T) { testConnectionAbort(t, &testOptions{}) }
445461
func TestConnectionAbort_worker(t *testing.T) {
446-
testConnectionAbort(t, &testOptions{workerScript: "connectionStatusLog.php"})
462+
testConnectionAbort(t, &testOptions{workerScript: "connection_status.php"})
447463
}
448464
func testConnectionAbort(t *testing.T, opts *testOptions) {
449465
testFinish := func(finish string) {
450466
t.Run(fmt.Sprintf("finish=%s", finish), func(t *testing.T) {
451-
logger, logs := observer.New(zapcore.InfoLevel)
452-
opts.logger = slog.New(zapslog.NewHandler(logger))
467+
var buf syncBuffer
468+
opts.logger = slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
453469

454470
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
455-
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/connectionStatusLog.php?i=%d&finish=%s", i, finish), nil)
471+
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/connection_status.php?i=%d&finish=%s", i, finish), nil)
456472
w := httptest.NewRecorder()
457473

458474
ctx, cancel := context.WithCancel(req.Context())
459475
req = req.WithContext(ctx)
460476
cancel()
461477
handler(w, req)
462478

463-
for logs.FilterMessage(fmt.Sprintf("request %d: 1", i)).Len() <= 0 {
479+
for !strings.Contains(buf.String(), fmt.Sprintf("request %d: 1", i)) {
464480
}
465481
}, opts)
466482
})
@@ -1058,7 +1074,6 @@ func FuzzRequest(f *testing.F) {
10581074
// Headers should always be present even if empty
10591075
assert.Contains(t, body, fmt.Sprintf("[CONTENT_TYPE] => %s", fuzzedString))
10601076
assert.Contains(t, body, fmt.Sprintf("[HTTP_FUZZED] => %s", fuzzedString))
1061-
10621077
}, &testOptions{workerScript: "request-headers.php"})
10631078
})
10641079
}

go.mod

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ require (
1111
github.com/maypok86/otter/v2 v2.2.1
1212
github.com/prometheus/client_golang v1.23.2
1313
github.com/stretchr/testify v1.11.1
14-
go.uber.org/zap v1.27.1
15-
go.uber.org/zap/exp v0.3.0
1614
golang.org/x/net v0.47.0
1715
)
1816

@@ -58,7 +56,6 @@ require (
5856
github.com/unrolled/secure v1.17.0 // indirect
5957
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
6058
go.etcd.io/bbolt v1.4.3 // indirect
61-
go.uber.org/multierr v1.11.0 // indirect
6259
go.yaml.in/yaml/v2 v2.4.3 // indirect
6360
go.yaml.in/yaml/v3 v3.0.4 // indirect
6461
golang.org/x/crypto v0.45.0 // indirect

go.sum

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,6 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
104104
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
105105
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
106106
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
107-
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
108-
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
109-
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
110-
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
111-
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
112-
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
113107
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
114108
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
115109
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=

log_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package frankenphp_test
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"log/slog"
7+
"sync"
8+
"testing"
9+
)
10+
11+
func newTestLogger(t *testing.T) (*slog.Logger, fmt.Stringer) {
12+
t.Helper()
13+
14+
var buf syncBuffer
15+
16+
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), &buf
17+
}
18+
19+
// SyncBuffer is a thread-safe buffer for capturing logs in tests.
20+
type syncBuffer struct {
21+
b bytes.Buffer
22+
mu sync.RWMutex
23+
}
24+
25+
func (s *syncBuffer) Write(p []byte) (n int, err error) {
26+
s.mu.Lock()
27+
defer s.mu.Unlock()
28+
29+
return s.b.Write(p)
30+
}
31+
32+
func (s *syncBuffer) String() string {
33+
s.mu.RLock()
34+
defer s.mu.RUnlock()
35+
36+
return s.b.String()
37+
}

phpmainthread_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package frankenphp
22

33
import (
44
"io"
5-
"log/slog"
65
"math/rand/v2"
76
"net/http/httptest"
87
"path/filepath"
@@ -119,7 +118,6 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) {
119118
WithWorkerWatchMode([]string{}),
120119
WithWorkerMaxFailures(0),
121120
),
122-
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
123121
))
124122

125123
// try all possible permutations of transition, transition every ms

0 commit comments

Comments
 (0)