Log4Net Tutorial pt 7: Filters

§ September 5, 2008 06:37 by beefarino |

In the Context tutorial I said that my next log4net post would focus on real-world examples.  However, I received this request from a college in the U.K.:

I would like to have 2 logs, ... I would like to have levels Debug, Info & Warn logged to the one log and all Error & Fatal logged to the second log. ...  Do you know if this is possible with log4net?

This is very easy to do using a feature of log4net I haven't discussed yet: filters.  Filters are applied to individual appenders via the log4net configuration, and they help the appender determine whether a log event should be processed by the appender.  They may sound like the appender threshold property, and indeed some filters behave similarly; however, filters offer far more control over logging behavior.

Filter Example

Open Visual Studio, create a new console project, and reference log4net.  Add the following code to the Program class:

namespace Tutorial7_Filters
{
    class Program
    {
        static void Main( string[] args )
        {
            log4net.Config.XmlConfigurator.Configure();
            log4net.ILog Log = log4net.LogManager.GetLogger(
                System.Reflection.MethodBase.GetCurrentMethod().DeclaringType
               );
            Log.Fatal( "this is a fatal msg" );
            Log.Error( "this is an error msg" );
            Log.Warn( "this is a warn msg" );
            Log.Info( "this is an info msg" );
            Log.Debug( "this is a debug msg" );
        }
    }
}

Add an app.config to the project and update it like so:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, log4net"/>
  </configSections>
  <log4net>
    <appender name="LogFileAppender" type="log4net.Appender.FileAppender">
      <file value="log.txt" />
      <filter type="log4net.Filter.LevelRangeFilter">
        <levelMin value="DEBUG" />
        <levelMax value="WARN" />
      </filter>
      <layout type="log4net.Layout.SimpleLayout" />
    </appender>
    <appender name="ErrorFileAppender" type="log4net.Appender.FileAppender">
      <file value="errors.txt" />
      <filter type="log4net.Filter.LevelRangeFilter">
        <levelMin value="ERROR" />
        <levelMax value="FATAL" />
      </filter>
      <layout type="log4net.Layout.SimpleLayout" />
    </appender>
    <root>
      <level value="DEBUG" />
      <appender-ref ref="LogFileAppender" />
      <appender-ref ref="ErrorFileAppender" />
    </root>
  </log4net>
</configuration>

This configuration defines two file appenders, each with a unique filter applied (lines 12 and 23).  The filter on the first appender will log events with levels DEBUG, INFO, and WARN; the filter on the second appender will log event with levels ERROR and FATAL.

Build and run; the command console will flash for a moment with no output.  Check the working directory and open log.txt:

WARN - this is a warn msg
INFO - this is an info msg
DEBUG - this is a debug msg

Note that only the DEBUG, INFO, and WARN messages appear in this file.  The errors.txt log file contains only ERROR and FATAL log messages:

FATAL - this is a fatal msg
ERROR - this is an error msg

Filter Survey

Here is a list of the filters available in the log4net distribution:

Type NameDescription
log4net.Filter.LevelMatchFilter Filters log events that match a specific logging level; alternatively this can be configured to filter events that DO NOT match a specific logging level.
log4net.Filter.LevelRangeFilter Similar to the LevelMatchFilter, except that instead of filtering a single log level, this filters on an inclusive range of contiguous levels.
log4net.Filter.LoggerMatchFilter Filters log events based on the name of the logger object from which they are emitted.
log4net.Filter.StringMatchFilter Filters log events based on a string or regular expression match against the log message.
log4net.Filter.PropertyFilter Filters log events based on a value or regular expression match against a specific context property.
log4net.Filter.DenyAllFilter Effectively drops all logging events for the appender.

You can also define your own filter by implementing the log4net.Filter.IFilter interface or deriving from the log4net.Filter.FilterSkeleton class.

You can apply multiple filters to an appender; they will be evaluated as a chain in configuration order.  Each filter in the chain can either accept the message, deny the message, or punt to the next filter in the chain.   The first filter to explicitly allow or deny a log message stops the filter chain and determines the fate of log message.

Logger and Level Filters

The LoggerMatchFilter filters against the name of the logger emitting the message.  This filter can be configured using the following properties:

  • loggerToMatch: a string value to match against the message's logger name.  The match is made using the String.StartsWith method;
  • acceptOnMatch: a boolean value indicating whether a matching logger name results in accepting the message (true) or rejecting it (false).  This defaults to true, meaning that only matching logger names will be allowed into the appender.

The LevelMatchFilter filters against a specific log message level.  This filter can be configured using the following properties:

  • levelToMatch: the log level to match - either DEBUG, INFO, WARN, ERROR, or FATAL;
  • acceptOnMatch: a boolean value indicating whether to accept log levels matching the levelToMatch property (true), or reject log levels matching the levelToMatch property (false).  Defaults to true.

The LevelRangeFilter allows you to configure a range of log levels to filter on:

  • levelMin: the minimum log level to match - either DEBUG, INFO, WARN, ERROR, or FATAL;
  • levelMax: the minimum log level to match - either DEBUG, INFO, WARN, ERROR, or FATAL;
  • acceptOnMatch: a boolean value indicating whether to accept log levels matching the levelToMatch property (true), or to punt filtering to the next filter in the chain (false).  Defaults to true.  Note that any log level outside of the [levelMin, levelMax] range is denied by this filter.

Message and Property Filters

If you need to filter messages based on their message content, you need StringMatchFilter.  This filter can be configured as follows:

  • regexToMatch: a regular expression to match against the log message.  Note this regex is created with the Compiled option enabled for performance;
  • stringToMatch: a static string to match against the log message.  The match is made using the String.IndexOf method to see if the static string exists in the log message, which is a case-sensitive search.
  • acceptOnMatch: a boolean value indicating whether to accept log messages matching the string or regex (true), or to deny log messages matching the string or regex (false).  Defaults to true.  

Note that any log messages not matching the filter criteria are punted to the next filter in the chain.  If you specify both the regexToMatch and stringToMatch filters, the regexToMatch wins out and the string comparison is never executed, even if the regex fails to match.

The PropertyFilter performs the same behavior as the StringMatchFilter, except that the match is made against the value of a context property.  It is configured like so:

  • key: the name of the property value to match;
  • regexToMatch: a regular expression to match against the specified property value.  Note this regex is created with the Compiled option enabled for performance;
  • stringToMatch: a static string to match against the specified property value.  The match is made using the String.IndexOf method to see if the static string exists in the property value, which is a case-sensitive search.
  • acceptOnMatch: a boolean value indicating whether to accept messages with a property value matching the string or regex (true), or to deny messages with a property value matching the string or regex (false).  Defaults to true.  

Just like the StringMatchFilter, the PropertyFilter will punt filtering when the property value does not match the filter criteria, and regexToMatch trumps and excludes stringToMatch.  In addition, if the property identified does not exist in the current context, filtering is punted.

DenyAllFilter

This filter simply denies all filtering.  When this is used, it's always at the end of a filter chain to block unwanted log messages from the appender.

An Extended Example

Update Program.cs to the following:

namespace Tutorial7_Filters
{
    class Program
    {
        static void Main( string[] args )
        {
            log4net.Config.XmlConfigurator.Configure();
            log4net.ILog Log = log4net.LogManager.GetLogger( System.Reflection.MethodBase.GetCurrentMethod().DeclaringType );
            log4net.ILog OMGLog = log4net.LogManager.GetLogger( "OMGLogger" );
            
            Log.Fatal( "this is a fatal msg" );
            Log.Error( "this is an error msg" );
            Log.Warn( "this is a warn msg" );
            Log.Info( "this is an info msg" );
            Log.Debug( "this is a debug msg" );
            OMGLog.Fatal( "OMG!!  this is a fatal msg" );
            OMGLog.Error( "OMG!!  this is an error msg" );
            OMGLog.Warn( "OMG!!  this is a warn msg" );
            OMGLog.Info( "OMG!!  this is an info msg" );
            OMGLog.Debug( "OMG!!  this is a debug msg" );
        }
    }
}

On lines 8 and 9 two logger objects are created, one based off of the name of the containing type and another named simply "OMGLogger".  Now update the app.config to the following:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, log4net"/>
  </configSections>
  <log4net>
    <appender name="OMGLogAppender" type="log4net.Appender.FileAppender">
      <file value="omglog.txt" />
      
      <filter type="log4net.Filter.LevelRangeFilter">
        <levelMin value="ERROR" />
        <levelMax value="FATAL" />
        <acceptOnMatch value="false" />
      </filter>
      <filter type="log4net.Filter.LoggerMatchFilter">
        <loggerToMatch value="OMGLogger" />
      </filter>
      <filter type="log4net.Filter.DenyAllFilter" />
      <layout type="log4net.Layout.SimpleLayout" />
    </appender>
    <root>
      <level value="DEBUG" />
      <appender-ref ref="OMGLogAppender" />
    </root>
  </log4net>
</configuration>


Compile and run, the command console will blip on your screen.  Open the omglog.txt file:

FATAL - OMG!!  this is a fatal msg
ERROR - OMG!!  this is an error msg

Note that because of our chained filter, only log messages that meet the following criteria were allowed into the omglog.txt file:

  1. the log message level is ERROR or FATAL;
  2. the log message originated from the OMGLogger.

Coming Up

For realsies next time I'll discuss some personal experiences with log4net saving me hours of work and making me a hero.  I'll also cover some logging best practices in that or a later post.



Log4Net Tutorial pt 6: Log Event Context

§ September 1, 2008 14:54 by beefarino |

There are times when your log messages need some context to be most useful.  For example, if your application supports multiple concurrent clients, it would be helpful to know the client to which each log statement applies.  If a piece of code is used by several components, it would be nice for the log messages to reflect which component is calling the code.  log4net provides a very simple mechanism to accommodate such needs.

Open Visual Studio, create a new console project, and add a reference to the log4net assembly.  Add the following code to your program.cs file:

namespace Tutorial6_Context
{
    class Program
    {
        private static log4net.ILog Log = log4net.LogManager.GetLogger( System.Reflection.MethodBase.GetCurrentMethod().DeclaringType );

        static void Main( string[] args )
        {
            log4net.Config.XmlConfigurator.Configure();
            log4net.ThreadContext.Properties[ "myContext" ] = "Logging from Main";
            Log.Info( "this is an info message" );
            Console.ReadLine();
        }
    }
}

Note line 10, where a property named "myContext" is added to the ThreadContext static class and assigned a simple string value.  Next add the following application configuration file to the project:

<configuration>
  <configSections>
    <section name="log4net"
      type="log4net.Config.Log4NetConfigurationSectionHandler, log4net"/>
  </configSections>

  <log4net>
 
    <appender name="ConsoleAppender" type="log4net.Appender.ConsoleAppender">
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="%logger (%property{myContext}) [%level]- %message%newline" />
      </layout>
    </appender>

    <root>
      <level value="ALL" />
      <appender-ref ref="ConsoleAppender" />
    </root>
  </log4net>
</configuration>

Line 11 contains the layout pattern for our log; the %property{myContext} format specifier will be replaced with the value of the "myContext" property at the time the message is logged.

Compile and run; the context value is reflected in the log output:

Tutorial6_Context.Program (Logging from Main) [INFO]- this is an info message

Context Scopes

There are actually three logging contexts available in log4net.  Like the logger architecture, contexts are organized into a hierarchy where properties in the more granular contexts override property values in less granular contexts.

ContextDescription
log4net.GlobalContext A global context shared across all application threads and app domains. If two threads set the same property on the GlobalContext, one value will overwrite the other.
log4net.ThreadContext Any properties set in this context are scoped to the calling thread. In other words, in this context two threads can set the same property to different values without stomping on each other.
log4net.ThreadLogicalContext This context behaves similarly to the ThreadContext, except that the scope is defined by logical thread boundaries. I'll be honest and say that I've never used the ThreadLogicalContext in my career, but if you're working with a custom thread pool algorithm or hosting the CLR, you may find some use for this one.

Calculated Context Values

Context property values don't have to be strings.  You can set the value of a context property to any object reference; the value of the object's ToString method will be used to obtain the context property value when a logging event occurs.  This can be useful when you need a context property to represent a calculated state at the time of each logging event.  For instance, perhaps you want to track your application's use of the CPU:

namespace Tutorial6_Context
{
    public class CalcualtedContextProperty
    {
        public override string ToString()
        {
            return Process.GetCurrentProcess().TotalProcessorTime.ToString();
        }
    }
}

Set a context property value to an instance of this new class:

namespace Tutorial6_Context
{
    class Program
    {
        private static log4net.ILog Log = log4net.LogManager.GetLogger( System.Reflection.MethodBase.GetCurrentMethod().DeclaringType );

        static void Main( string[] args )
        {
            log4net.Config.XmlConfigurator.Configure();
            log4net.ThreadContext.Properties[ "myContext" ] = new CalculatedContextProperty();
            Log.Info( "this is an info message" );
            Console.ReadLine();
        }
    }
}

The result is each log entry containing the application's total CPU time at the time the log entry was made:

Tutorial6_Context.Program (00:00:00.2968750) [INFO]- this is an info message

ThreadContext Stacks

ThreadContext and ThreadLogicalContext can store property values in stacks (available via the Stacks static property of each class).  These stacks are very handy, as they allow you to track program states in the context of a log message:

namespace Tutorial6_Context
{
    class Program
    {
        static log4net.ILog Log = null;

        static void Main( string[] args )
        {
            log4net.Config.XmlConfigurator.Configure();
            Log = log4net.LogManager.GetLogger( System.Reflection.MethodBase.GetCurrentMethod().DeclaringType );
            using( log4net.ThreadContext.Stacks[ "myContext" ].Push( "outer" ) )
            {
                Log.Info( "this is the first message" );
                using( log4net.ThreadContext.Stacks[ "myContext" ].Push( "inner" ) )
                {
                    Log.Info( "this is the second message" );
                }
            }
            Log.Info( "this is the third message" );
            Console.ReadLine();
        }
    }
}

Pushing a property value onto a stack returns an IDisposable that, when disposed, pops the property value off of the stack.  The logging output reflects the state of the context stack at each logging event; the stack is represented as a space-delimited list, with newer items appearing later in the list:

Tutorial6_Context.Program (outer) [INFO]- this is the first message
Tutorial6_Context.Program (outer inner) [INFO]- this is the second message
Tutorial6_Context.Program ((null)) [INFO]- this is the third message

Context stacks are incredibly useful for wrapping access to a shared piece of code.  For example, in the following code two methods call the same utility routine; a marker is pushed onto the property stack before each call:

namespace Tutorial6_Context
{
    class Program
    {
        static log4net.ILog Log = null;

        static void Main( string[] args )
        {
            log4net.Config.XmlConfigurator.Configure();
            Log = log4net.LogManager.GetLogger( System.Reflection.MethodBase.GetCurrentMethod().DeclaringType );

            FirstAction();
            SecondAction();

            Console.ReadLine();

        }

        static void FirstAction()
        {
            using( log4net.ThreadContext.Stacks[ "myContext" ].Push( "FirstAction" ) )
            {
                UtilityRoutine();
            }
        }

        static void SecondAction()
        {
            using( log4net.ThreadContext.Stacks[ "myContext" ].Push( "SecondAction" ) )
            {
                UtilityRoutine();
            }
        }
        
        static void UtilityRoutine()
        {
            Log.Info( "this is an info message" );
        }
    }
}

Each log message produced by the utility routine reflects the context in which it was called:

Tutorial6_Context.Program (from:FirstAction) [INFO]- this is an info message
Tutorial6_Context.Program (from:SecondAction) [INFO]- this is an info message

Coming Up

These 6 tutorials cover the logging mechanics available in log4net.  So in the next post or two, I'd like to show you how logging has saved my hide using real examples from my career.



Using Overlapped I/O from Managed Code

§ July 26, 2008 04:01 by beefarino |

I've been working on a project that requires some low-level device driver interaction using the DeviceIoControl Windows API call.  Without going into too much detail, the driver's programming interface requires a thread-heavy implementation to monitor low-level GPIO inputs for signal changes.  Fortunately Windows has built-in an asynchronous programming mode called Overlapped I/O that saves me the overhead of having to manage a bunch of threads for such low-level tripe.  In a nutshell, Overlapped I/O is the backbone of the asynchronous reading and writing of files and network streams in the .NET framework.

Unfortunately, there is no managed equivalent for device communication in the .NET framework; hand-rolling access to the DeviceIoControl API call is simple enough, but leveraging overlapped I/O from managed code requires some digging.  There are very few examples available, so I decided to post one in case anyone else out there happens to be working on such obscure (but very interesting!) projects too.

There are five steps to leveraging Overlapped I/O from managed code:

  1. Access the relevant routines from Kernel32.dll via P/Invoke;
  2. Create a file/device/pipe handle tagged for overlapped I/O;
  3. Bind the handle to the managed Thread Pool;
  4. Prepare a NativeOverlapped pointer;
  5. Pass the NativeOverlapped pointer to the relevant Win32 API (DeviceIoControl in my case).

Kernel32 P/Invoke

Using overlapped I/O requires some direct access to routines from kernel32.dll.  Below is the a static class that imports the routines and defines the constants needed.  If you have any questions or suggestions please feel free to post a comment.

using System;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
namespace PInvoke
{
    /// <summary>
    /// Static imports from Kernel32.
    /// </summary>
    static public class Kernel32
    {
        const uint Overlapped = 0x40000000;
        
        [ Flags ]
        public enum AccessRights : uint
        {
            GENERIC_READ                     = (0x80000000),
            GENERIC_WRITE                    = (0x40000000),
            GENERIC_EXECUTE                  = (0x20000000),
            GENERIC_ALL                      = (0x10000000)
        }
        [ Flags ]
        public enum ShareModes : uint
        {
            FILE_SHARE_READ                 = 0x00000001,
            FILE_SHARE_WRITE                = 0x00000002,
            FILE_SHARE_DELETE               = 0x00000004  
        }
        public enum CreationDispositions
        {
            CREATE_NEW          = 1,
            CREATE_ALWAYS       = 2,
            OPEN_EXISTING       = 3,
            OPEN_ALWAYS         = 4,
            TRUNCATE_EXISTING   = 5
        }
        [ DllImport( "kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode ) ]
        public static extern SafeFileHandle CreateFile(
            string lpFileName,
            uint dwDesiredAccess,
            uint dwShareMode,
            IntPtr lpSecurityAttributes,
            uint dwCreationDisposition,
            uint dwFlagsAndAttributes,
            IntPtr hTemplateFile
        );
        [ DllImport( "kernel32.dll", CharSet=CharSet.Unicode ) ]
        public static extern void CloseHandle(
            SafeHandle handle
        );
        [ DllImport( "kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode ) ]
        unsafe public static extern bool DeviceIoControl(
            SafeHandle hDevice,
            uint dwIoControlCode,
            IntPtr lpInBuffer,
            uint nInBufferSize,
            IntPtr lpOutBuffer,
            uint nOutBufferSize,
            ref uint lpBytesReturned,
            NativeOverlapped *lpOverlapped
        );
    }
}

Creating the Device Handle

In order to leverage asynchronous I/O against the device handle, it needs to be opened specifically for asynchronous operations.  To do this use the CreateFile API call, passing the device path as the lpFileName parameter and specifying the Overlapped flag in the dwFlagsAndAttributes parameter:

SafeFileHandle deviceHandle = Kernel32.CreateFile(
    devicePath,
    ( uint )( Kernel32.AccessRights.GENERIC_READ | Kernel32.AccessRights.GENERIC_WRITE ),
    ( uint )( Kernel32.ShareModes.FILE_SHARE_READ | Kernel32.ShareModes.FILE_SHARE_WRITE ),
    IntPtr.Zero,
    ( uint )( Kernel32.CreationDispositions.OPEN_EXISTING ),
    Kernel32.Overlapped, // note the OVERLAPPED flag here
    IntPtr.Zero
);                    

Binding the Device Handle to the Thread Pool

The Windows Thread Pool manages 1000 threads per process dedicated to nothing but handling overlapped I/O.  In order to make use of them, it is necessary to "bind" the device handle returned by calling CreateFile to the thread pool.  This is done using the ThreadPool.BindHandle call:

ThreadPool.BindHandle( deviceHandle );             

The API documentation for BindHandle gives almost no indication as to it's purpose, but I've found this call is absolutely necessary in order to receive notifications of asynchronous operations.  (If you're interested in the details of what this call actually accomplishes, it seems to bind the handle to an I/O Completion Port owned by the Thread Pool; check out Chris Brumme's comments from this post on his blog for more.)

Creating a NativeOverlapped Pointer

Creating a NativeOverlapped pointer to pass to the DeviceIoControl API is relatively simple:

Overlapped overlapped = new Overlapped();
NativeOverlapped* nativeOverlapped = overlapped.Pack(
    DeviceWriteControlIOCompletionCallback,
    null
);             

After creating an instance of the Overlapped class, use its Pack method to create a pointer to a NativeOverlapped structure; the layout of this structure is byte-for-byte identical to the Win32 OVERLAPPED structure, which makes it suitable for passing directly to unmanaged API calls.  Note that the pointer returned by Overlapped.Pack() is fixed (pinned) in memory until the pointer is passed to Overlapped.Unpack and Overlaped.Free.

The first parameter to Overlapped.Pack is an IOCompletionCallback delegate that will be called when the overlapped I/O operation completes.  The second argument could be an array of objects representing buffers of memory used for passing data to and from the device driver; to keep this post as simple as possible I'm leaving that feature out of the example code.

The IOCompletionCallback delegate is mandatory and follows the following pattern:

unsafe void DeviceWriteControlIOCompletionCallback( uint errorCode, uint numBytes, NativeOverlapped* nativeOverlapped )
{    
    try
    {
        // ...
    }
    finally
    {
          System.Threading.Overlapped.Unpack( nativeOverlapped );
          System.Threading.Overlapped.Free( nativeOverlapped );
    }
}

The errorCode parameter will be 0 on a successful overlapped I/O operation, or a standard Win32 error code on a failure.  The numBytes parameter will contain the number of bytes received from the operation.

With whatever else needs to happen post-I/O, the fixed NativeOverlapped pointer must be unpacked and freed, or the application will both leak memory and fragment the managed heap over time.

Passing the NativeOverlapped Pointer to DeviceIoControl

With a NativeOverlapped pointer in hand, call DeviceIoControl:

 

const int ErrorIOPending = 997;
uint bytesReturned = 0;
bool result = Kernel32.DeviceIoControl(
    deviceHandle,
    ( uint )ioCtlCode,
    IntPtr.Zero,
    0,
    IntPtr.Zero,
    0,
    ref bytesReturned,
    nativeOverlapped
);
if( result )
{
    // operation completed synchronously    
    
    System.Threading.Overlapped.Unpack( nativeOverlapped );
    System.Threading.Overlapped.Free( nativeOverlapped );
}
else
{
    int error = Marshal.GetLastWin32Error();    
    if( ErrorIOPending != error )
    {
        // failed to execute DeviceIoControl using overlapped I/O ...
        
        System.Threading.Overlapped.Unpack( nativeOverlapped );
        System.Threading.Overlapped.Free( nativeOverlapped );
    }
}

 

The nativeOverlapped pointer and overlapped device handle cause the kernel to attempt an asynchronous execution of DeviceIoControl.  

The call will return immediately with a boolean result whose semantics are a bit awkward.  If the result is true, it means the operation completed synchronously as opposed to asynchronously.  A false result indicates the operation may have be executing asynchronously.  To verify this, check the Win32 error code via Marshal.GetLastWin32Error: if it's ERROR_IO_PENDING (997), the DeviceIoControl operation is executing asynchronously and the IOCompletionCallback delegate will be called when the operation is finished; otherwise, the error code will indicate why DeviceIoControl failed to execute asynchronously.

If DeviceIoControl executes synchronously, or if it fails to execute asynchronously, the NativeOverlapped pointer must still be unpacked and freed to avoid leaking memory and fragmenting the managed heap.

That's the nut of using Overlapped I/O from managed code.  If you do end up using this for something, please drop me a line, either as a comment to this post or using the contact link on this site; I'd love to hear what you're working on!



On Smarts, Curiosity, and Learning

§ July 6, 2008 14:22 by beefarino |

Justin Etheredge recently posted Being Smart Does Not a Good Developer Make, in which he offers several important perspectives in his usual good-hearted way; the one that sparked my neurons was that drive and curiosity are more valuable than raw knowledge to becoming a "good developer."

I whole-heartedly share this perspective.  This is probably due to the fact that I've painted myself into a professional corner in many respects.  I have no formal education in my chosen profession of software engineering, which has historically put me at a disadvantage in job interviews, when I push to lead a project, and in a lot of interaction with my peers.  Nevertheless, I've been able to land the jobs, get the project leads when I want them, and feel confident - if reservedly so - talking with my fellow engineers without sounding like a total poser.

I attribute my success to three things:

  1. I've been pretty lucky with my opportunities;
  2. I'm insatiably curious about most things;
  3. I have two degrees in psychology. 

Justin calls software engineering a meta-industry; likewise, psychology is probably the second-best example of a meta-science I can think of (with first place going to cryptography).  Digesting all that research on learning, perception, and memory has given me a significant leg-up when it comes to learning new things, understanding someone else's perspective, and communication.

I honestly believe that having and honing these skills is what keeps me employed as a software engineer.

If it was possible for me to add any value to Justin's post, it would be to not limit your exploration to your career.  Learning is an active, forceful process - go find something that interests you and relate it back to software design and engineering.  Psychology may not be your bag, but learning opportunities abound.  Here are two ideas I've taken from my own personal history:

Design and build a birdhouse (or, if you have some modest woodworking skills, a tree house or bookshelf).  If you really want a challenge, have someone else design it.  What sort of problems did you hit during design vs. construction?  How well did you estimate your materials?  What sort of mistakes ended up in the final product, and how would you avoid them next time?

Cook a menu by a fickle four-year-old.  This is a great exercise in communicating across domains of expertise: you could be a Master Chef, but you'll have no idea how to make Glittery Rainbow Unicorn Fishsticks or Chocolate Marshmallow Beans and Rice without some discussion.  Moreover, try explaining to them why their culinary delights don't taste as delightful as they expected.  Work with them to hone their ideas and produce something palatable.  Think about the communication problems you experienced and how you worked around them - they have a lot in common with communication problems you experience at work.